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   24875 use strict;
  1         2  
  1         34  
74 1     1   5 use warnings;
  1         2  
  1         32  
75 1     1   1238 use LWP::UserAgent;
  1         59908  
  1         55  
76              
77             package WWW::Shopify;
78              
79             our $VERSION = '1.01';
80              
81 1     1   599 use WWW::Shopify::Exception;
  1         3  
  1         25  
82 1     1   624 use WWW::Shopify::Field;
  1         4  
  1         60  
83 1     1   427 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( 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.22 (KHTML, like Gecko) Chrome/25.0.1364.5 Safari/537.22");
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             $self->login_admin($email, $password) if defined $email && defined $password;
115             return $self;
116             }
117              
118              
119             sub api_calls { $_[0]->{_api_calls} = $_[1] if defined $_[1]; return $_[0]->{_api_calls}; }
120             sub url_handler { $_[0]->{_url_handler} = $_[1] if defined $_[1]; return $_[0]->{_url_handler}; }
121             sub sleep_for_limit { $_[0]->{_sleep_for_limit} = $_[1] if defined $_[1]; return $_[0]->{_sleep_for_limit}; }
122             sub last_timestamp { $_[0]->{_last_timestamp} = $_[1] if defined $_[1]; return $_[0]->{_last_timestamp}; }
123              
124             =head2 encode_url($url)
125              
126             Basic url encoding, works the same for public apps or logged-in apps.
127              
128             =cut
129              
130             sub encode_url { return "https://" . $_[0]->shop_url . $_[1]; }
131              
132              
133             =head2 ua([$new_ua])
134              
135             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.
136              
137             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
138              
139             LWP::UserAgent->new( ssl_opts => { SSL_version => 'TLSv12' } );
140              
141             Otherwise, Shopify does some very weird stuff, and some very weird errors are spit out. Just FYI.
142              
143             =cut
144              
145             sub ua { $_[0]->{_ua} = $_[1] if defined $_[1]; return $_[0]->{_ua}; }
146              
147              
148             =head2 shop_url([$shop_url])
149              
150             Gets/sets the shop url that we're going to be making calls to.
151              
152             =cut
153              
154             # Modifiable Attributes
155             sub shop_url { $_[0]->{_shop_url} = $_[1] if defined $_[1]; return $_[0]->{_shop_url}; }
156              
157             sub translate_model($) {
158             return $_[1] if $_[1] =~ m/WWW::Shopify::Model/;
159             return "WWW::Shopify::Model::" . $_[1];
160             }
161              
162             sub PULLING_ITEM_LIMIT { return 250; }
163             #sub CALL_LIMIT_REFRESH { return 60*5; }
164             #sub CALL_LIMIT_MAX { return 500; }
165             sub CALL_LIMIT_MAX { return 40; }
166             sub CALL_LIMIT_LEAK_TIME { return 1; }
167             sub CALL_LIMIT_LEAK_RATE { return 2; }
168              
169             sub get_url { return $_[0]->url_handler->get_url($_[1], $_[2], $_[3], $_[4], $_[5]); }
170             sub post_url { return $_[0]->url_handler->post_url($_[1], $_[2], $_[3], $_[4], $_[5]); }
171             sub put_url { return $_[0]->url_handler->put_url($_[1], $_[2], $_[3], $_[4], $_[5]); }
172             sub delete_url { return $_[0]->url_handler->delete_url($_[1], $_[2], $_[3], $_[4], $_[5]); }
173              
174             use Data::Dumper;
175             sub use_url {
176             my ($self, $type, $url, @args) = @_;
177             my $method = lc($type) . "_url";
178             my ($decoded, $response);
179             $url = $self->encode_url($url);
180             eval {
181             if ($self->sleep_for_limit) {
182             do {
183             eval { ($decoded, $response) = $self->$method($url, @args); };
184             if (my $exp = $@) {
185             die $exp if !ref($exp) || ref($exp) ne 'WWW::Shopify::Exception::CallLimit';
186             sleep(1);
187             }
188             } while (!$response);
189             } else {
190             ($decoded, $response) = $self->$method($url, @args);
191             }
192             };
193             if (my $exp = $@) {
194             print STDERR Dumper($exp->error) if $ENV{'SHOPIFY_LOG'} && $ENV{'SHOPIFY_LOG'} > 1;
195             die $exp;
196             }
197             print STDERR uc($type) . " " . $response->request->uri . "\n" if $ENV{'SHOPIFY_LOG'} && $ENV{'SHOPIFY_LOG'} == 1;
198             print STDERR Dumper($response) if $ENV{'SHOPIFY_LOG'} && $ENV{'SHOPIFY_LOG'} > 1;
199             $self->last_timestamp(DateTime->from_epoch( epoch => str2time($response->header('Date'))) );
200             return ($decoded, $response);
201             }
202              
203             use Devel::StackTrace;
204             sub resolve_trailing_url {
205             my ($self, $package, $action, $parent, $specs) = @_;
206             $package = ref($package) if ref($package);
207             my $method = lc($action) . "_through_parent";
208             if ($package->$method && (!$parent || !$parent->is_shop || $package ne "WWW::Shopify::Model::Metafield")) {
209             die new WWW::Shopify::Exception("Cannot get, no parent specified.") unless $parent;
210             if ($package eq "WWW::Shopify::Model::Metafield" && ref($parent) eq 'WWW::Shopify::Model::Product::Image' && $specs) {
211             $specs->{owner_id} = $parent->id;
212             $specs->{owner_resource} = "product_image";
213             return "/admin/" . $package->plural;
214             }
215             return "/admin/" . $parent->plural . "/" . $parent->id . "/" . $package->plural;
216             }
217             return "/admin/" . $package->plural;
218             }
219              
220             sub get_all_limit {
221             my ($self, $package, $specs) = @_;
222             $package = $self->translate_model($package);
223             $specs->{"limit"} = PULLING_ITEM_LIMIT unless exists $specs->{"limit"};
224             return () if ($specs->{limit} == 0);
225             return $self->get_shop if $package->is_shop;
226             my $url = $self->resolve_trailing_url($package, "get", $specs->{parent}, $specs) . ".json";
227             my ($decoded, $response) = $self->use_url('get', $url, $specs);
228             my @return = map { my $object = $package->from_json($_, $self); $object->associated_parent($specs->{parent}); $object; } @{$decoded->{$package->plural}};
229             return @return;
230             }
231              
232             =head2 get_all($self, $package, $filters)
233              
234             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.
235              
236             @products = $sa->get_all("Product")
237              
238             If you don't want this behaviour, use the limit filter.
239              
240             =cut
241              
242             use POSIX qw/ceil/;
243             use List::Util qw(min);
244             sub get_all {
245             my ($self, $package, $specs) = @_;
246             # We copy our specs so that we don't modify the original hash. Doesn't have to be a deep copy.
247             $specs = {%$specs} if $specs;
248             $package = $self->translate_model($package);
249             $self->validate_item($package);
250             return $self->get_shop if $package->is_shop;
251             return $self->get_all_limit($package, $specs) if ((defined $specs->{"limit"} && $specs->{"limit"} <= PULLING_ITEM_LIMIT) || !$package->countable());
252            
253             my @return;
254             eval {
255             $specs->{page} = 1;
256             my @chunk;
257             do {
258             @chunk = $self->get_all_limit($package, $specs);
259             push(@return, @chunk);
260             $specs->{page}++;
261             } while (int(@chunk) == $specs->{limit});
262             };
263             if (my $exception = $@) {
264             $exception->extra(\@return) if ref($exception) && $exception->isa('WWW::Shopify::Exception::CallLimit');
265             die $exception;
266             }
267             return @return if wantarray;
268             return $return[0];
269             }
270              
271             =head2 get_shop($self)
272              
273             Returns the actual shop object.
274              
275             my $shop = $sa->get_shop;
276              
277             =cut
278              
279             sub get_shop {
280             my ($self) = @_;
281             my $package = 'WWW::Shopify::Model::Shop';
282             my ($decoded, $response) = $self->use_url('get', "/admin/" . $package->singular() . ".json");
283             my $object = $package->from_json($decoded->{$package->singular()}, $self);
284             return $object;
285             }
286              
287             =head2 get_timestamp($self)
288              
289             Uses a call to Shopify to determine the DateTime on the shopify server. This can be used to synchronize things without worrying about the
290             local clock being out of sync with Shopify.
291              
292             =cut
293              
294             sub get_timestamp {
295             my ($self) = @_;
296             my $ua = $self->ua;
297             my ($decoded, $response) = $self->use_url('get', "/admin/shop.json");
298             my $date = $response->header('Date');
299             my $time = str2time($date);
300             return DateTime->from_epoch( epoch => $time );
301             }
302              
303             =head2 get_count($self, $package, $filters)
304              
305             Gets the item count from the shopify store. So if we wanted to count all our orders, we'd do:
306              
307             my $order = $sa->get_count('Order', { status => "any" });
308              
309             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".
310              
311             =cut
312              
313             sub get_count {
314             my ($self, $package, $specs) = @_;
315             $package = $self->translate_model($package);
316             $self->validate_item($package);
317             die "Cannot count $package." unless $package->countable();
318             my ($decoded, $response) = $self->use_url('get', $self->resolve_trailing_url($package, "get", $specs->{parent}, $specs) . "/count.json", $specs);
319             return $decoded->{'count'};
320             }
321              
322             =head2 get($self, $package, $id)
323              
324             Gets the item from the shopify store. Returns it in local (classed up) form. In order to get an order for example:
325              
326             my $order = $sa->get('Order', 142345);
327              
328             It's as easy as that. If we don't retrieve anything, we return undef.
329              
330             =cut
331              
332             sub get {
333             my ($self, $package, $id, $specs) = @_;
334             $package = $self->translate_model($package);
335             $self->validate_item($package);
336             # We have a special case for asssets, for some arbitrary reason.
337             my ($decoded, $response);
338             eval {
339             if ($package !~ m/Asset/) {
340             ($decoded, $response) = $self->use_url('get', $self->resolve_trailing_url($package, "get", $specs->{parent}) . "/$id.json");
341             } else {
342             die new WWW::Shopify::Exception("MUST have a parent with assets.") unless $specs->{parent};
343             ($decoded, $response) = $self->use_url('get', "/admin/themes/" . $specs->{parent}->id . "/assets.json", {'asset[key]' => $id, theme_id => $specs->{parent}->id});
344             }
345             };
346             if (my $exp = $@) {
347             return undef if ref($exp) && $exp->isa("WWW::Shopify::Exception::NotFound");
348             die $exp;
349             }
350             my $class = $package->from_json($decoded->{$package->singular()}, $self);
351             # Wow, this is straight up stupid that sometimes we don't get a 404.
352             return undef unless $class;
353             $class->associated_parent($specs->{parent});
354             return $class;
355             }
356              
357             =head2 search($self, $package, $item, { query => $query })
358              
359             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.
360              
361             A popular thing to search for is customers by email, you can do so like the following:
362              
363             my $customer = $sa->search("Customer", { query => "email:me@example.com" });
364              
365             =cut
366              
367             sub search {
368             my ($self, $package, $specs) = @_;
369             $package = $self->translate_model($package);
370             die new WWW::Shopify::Exception("Unable to search $package; it is not marked as searchable in Shopify's API.") unless $package->searchable;
371             die new WWW::Shopify::Exception("Must have a query to search.") unless $specs && $specs->{query};
372             $self->validate_item($package);
373              
374             my ($decoded, $response) = $self->use_url('get', $self->resolve_trailing_url($package, "get", $specs->{parent}) . "/search.json", $specs);
375              
376             my @return = ();
377             foreach my $element (@{$decoded->{$package->plural()}}) {
378             my $class = $package->from_json($element, $self);
379             $class->associated_parent($specs->{parent}) if $specs->{parent};
380             push(@return, $class);
381             }
382             return @return if wantarray;
383             return $return[0] if int(@return) > 0;
384             return undef;
385             }
386              
387             =head2 create($self, $item)
388              
389             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.
390              
391             =cut
392              
393             use List::Util qw(first);
394             use HTTP::Request::Common;
395             sub create {
396             my ($self, $item, $options) = @_;
397            
398             $self->validate_item(ref($item));
399             my $specs = {};
400             my $missing = first { !exists $item->{$_} } $item->creation_minimal;
401             die new WWW::Shopify::Exception("Missing minimal creation member: $missing in " . ref($item)) if $missing;
402             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;
403             $specs = $item->to_json();
404             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);
405             my $element = $decoded->{$item->singular};
406             my $object = ref($item)->from_json($element, $self);
407             $object->associated_parent($item->associated_parent);
408             return $object;
409             }
410              
411             =head2 update($self, $item)
412              
413             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.
414              
415             =cut
416              
417             sub update {
418             my ($self, $class) = @_;
419             $self->validate_item(ref($class));
420             my %mods = map { $_ => 1 } $class->update_fields;
421             my $vars = $class->to_json();
422             $vars = { $class->singular => {map { $_ => $vars->{$_} } grep { exists $mods{$_} } keys(%$vars)} };
423              
424             my ($decoded, $response);
425             if (ref($class) =~ m/Asset/) {
426             my $url = $self->resolve_trailing_url(ref($class), "update", $class->associated_parent) . ".json";
427             ($decoded, $response) = $self->use_url($class->update_method, $url, $vars);
428             }
429             else {
430             ($decoded, $response) = $self->use_url($class->update_method, $self->resolve_trailing_url($class, "update", $class->associated_parent) . "/" . $class->id . ".json", $vars);
431             }
432              
433             my $element = $decoded->{$class->singular()};
434             my $object = ref($class)->from_json($element, $self);
435             $object->associated_parent($class->associated_parent);
436             return $object;
437             }
438              
439             =head2 delete($self, $item)
440              
441             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.
442              
443             =cut
444              
445             sub delete {
446             my ($self, $class) = @_;
447             $self->validate_item(ref($class));
448             if (ref($class) =~ m/Asset/) {
449             my $url = $self->resolve_trailing_url(ref($class), "delete", $class->associated_parent) . ".json?asset[key]=" . $class->key;
450             $self->use_url($class->delete_method, $url);
451             }
452             else {
453             $self->use_url($class->delete_method, $self->resolve_trailing_url($class, "delete", $class->associated_parent) . "/" . $class->id . ".json");
454             }
455             return 1;
456             }
457              
458             # For simple things like activating, enabling, disabling, that are a simple post to a custom URL.
459             # Sometimes returns an object, sometimes returns a 1.
460             use List::Util qw(first);
461             sub custom_action {
462             my ($self, $object, $action) = @_;
463             die new WWW::Shopify::Exception("You can't $action " . $object->plural . ".") unless defined $object && first { $_ eq $action } $object->actions;
464             my $id = $object->id;
465             my $url = $self->resolve_trailing_url($object, $action, $object->associated_parent) . "/$id/$action.json";
466             my ($decoded, $response) = $self->use_url('post', $url, {$object->singular() => $object->to_json});
467             return 1 if !$decoded;
468             my $element = $decoded->{$object->singular()};
469             $object = ref($object)->from_json($element, $self);
470             return $object;
471             }
472              
473             =head2 activate($self, $charge), disable($self, $discount), enable($self, $discount), open($self, $order), close($self, $order), cancel($self, $order)
474              
475             Special actions that do what they say.
476              
477             =cut
478              
479             sub activate { return $_[0]->custom_action($_[1], "activate"); }
480             sub disable { return $_[0]->custom_action($_[1], "disable"); }
481             sub enable { return $_[0]->custom_action($_[1], "enable"); }
482             sub open { return $_[0]->custom_action($_[1], "open"); }
483             sub close { return $_[0]->custom_action($_[1], "close"); }
484             sub cancel { return $_[0]->custom_action($_[1], "cancel"); }
485             sub approve { return $_[0]->custom_action($_[1], "approve"); }
486             sub remove { return $_[0]->custom_action($_[1], "remove"); }
487             sub spam { return $_[0]->custom_action($_[1], "spam"); }
488             sub not_spam { return $_[0]->custom_action($_[1], "not_spam"); }
489              
490             =head2 login_admin($self, $email, $password)
491              
492             Logs you in to the shop as an admin, allowing you to create and manipulate discount codes, as well as upload files into user-space (not theme space).
493              
494             Doens't get around the API call limit, unfortunately.
495              
496             =cut
497              
498             use HTTP::Request::Common;
499             sub login_admin {
500             my ($self, $username, $password) = @_;
501             return 1 if $self->{last_login_check} && (time - $self->{last_login_check}) < 1000;
502             my $ua = $self->ua;
503             die new WWW::Shopify::Exception("Unable to login as admin without a cookie jar.") unless defined $ua->cookie_jar;
504             my $res = $ua->get("https://" . $self->shop_url . "/admin/auth/login");
505             die new WWW::Shopify::Exception("Unable to get login page.") unless $res->is_success;
506             die new WWW::Shopify::Exception("Unable to find authenticity token.") unless $res->decoded_content =~ m/name="authenticity_token".*?value="(\S+)"/ms;
507             my $authenticity_token = $1;
508             my $req = POST "https://" . $self->shop_url . "/admin/auth/login", [
509             login => $username,
510             password => $password,
511             remember => 1,
512             commit => "Sign In",
513             authenticity_token => $authenticity_token
514             ];
515             $res = $ua->request($req);
516             die new WWW::Shopify::Exception("Unable to complete request: " . $res->decoded_content) unless $res->is_success || $res->code == 302;
517             die new WWW::Shopify::Exception("Unable to login: $1.") if $res->decoded_content =~ m/class="status system-error">(.*?)<\/div>/;
518             $self->{last_login_check} = time;
519             $self->{authenticity_token} = $authenticity_token;
520             $res = $self->ua->request(GET "https://" . $self->shop_url . "/admin");
521             die new WWW::Shopify::Exception($res) unless $res->decoded_content =~ m/meta name="csrf-token" content="(.*?)"/;
522             $self->{authenticity_token} = $1;
523             $ua->default_header('X-CSRF-Token' => $self->{authenticity_token});
524             return 1;
525             }
526              
527             =head2 logged_in_admin($self)
528              
529             Determines whether or not you're logged in to the Shopify store as an admin.
530              
531             =cut
532              
533             sub logged_in_admin {
534             my ($self) = @_;
535             return undef unless $self->{authenticity_token};
536             return 1 if $self->{last_login_check} && (time - $self->{last_login_check}) < 1000;
537             my $ua = $self->ua;
538             return undef unless $ua->cookie_jar;
539             my $res = $ua->get('https://' . $self->shop_url . '/admin/discounts/count.json');
540             return undef unless $res->is_success;
541             $self->{last_login_check} = time;
542             return 1;
543             }
544              
545             sub is_valid { eval { $_[0]->get_shop; }; return undef if ($@); return 1; }
546             sub handleize {
547             my ($self, $handle) = @_;
548             $handle = $self if !ref($self);
549             $handle = lc($handle);
550             $handle =~ s/\s/-/g;
551             $handle =~ s/[^a-z0-9\-]//g;
552             $handle =~ s/\-+/-/g;
553             return $handle;
554             }
555              
556              
557             =head2 create_private_app()
558              
559             Automates a form submission to generate a private app. Returns a WWW::Shopify::Private with the appropriate credentials. Must be logged in.
560              
561             =cut
562              
563             use WWW::Shopify::Private;
564             use List::Util qw(first);
565             sub create_private_app {
566             my ($self) = @_;
567             my $app = $self->create(new WWW::Shopify::Model::APIClient({}));
568             my @permissions = $self->get_all("APIPermission");
569             my $permission = first { $_->api_client->api_key eq $app->api_key } @permissions;
570             return new WWW::Shopify::Private($self->shop_url, $app->api_key, $permission->access_token);
571             }
572              
573              
574             =head2 delete_private_app($private_api)
575              
576             Removes a private app. Must be logged in.
577              
578             =cut
579              
580             sub delete_private_app {
581             my ($self, $api) = @_;
582             my @apps = $self->get_all("APIPermission");
583             my $app = first { $_->api_client && $_->api_client->api_key eq $api->api_key } @apps;
584             die new WWW::Shopify::Exception("Can't find app with api key " . $api->api_key) unless $app;
585             return $self->delete(new WWW::Shopify::Model::APIClient({ id => $app->api_client->id }));
586             }
587              
588              
589             # Internal methods.
590             sub validate_item {
591             eval { die unless $_[1]; $_[1]->is_item; };
592             die new WWW::Shopify::Exception($_[1] . " is not an item!") if ($@);
593             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;
594             }
595              
596              
597             =head2 upload_files($self, @image_paths)
598              
599             Requires log in. Uploads an array of files/images into the shop's non-theme file/image management system by automating a form submission.
600              
601             $sa->login_admin("email", "password");
602             $sa->upload_files("image1.jpg", "image2.jpg");
603              
604             Gets around the issue that this is not actually exposed to the API.
605              
606             =cut
607              
608             use JSON qw(decode_json);
609              
610             sub upload_files {
611             my ($self, @images) = @_;
612             die new WWW::Shopify::Exception("Uploading files/images requires you to login with an admin account.") unless $self->logged_in_admin;
613             my @returns;
614             foreach my $path (@images) {
615             die new WWW::Shopify::Exception("Unable to determine extension type.") unless $path =~ m/\.(\w{2,4})$/;
616             my $req = POST "https://" . $self->shop_url . "/admin/settings/files.json",
617             Content_Type => "form-data",
618             Accept => "*/*",
619             Content => [authenticity_token => $self->{authenticity_token}, "file[file]" => [$path]];
620             my $res = $self->ua->request($req);
621             print STDERR Dumper($res) if $ENV{'SHOPIFY_LOG'} && $ENV{'SHOPIFY_LOG'} == 2;
622             die new WWW::Shopify::Exception("Error uploading $path.") unless $res->is_success;
623             push(@returns, WWW::Shopify::Model::File->from_json(decode_json($res->decoded_content)->{file}));
624             }
625             return @returns;
626             }
627              
628             =cut
629              
630             =head1 EXPORTED FUNCTIONS
631              
632             The functions below are exported as part of the package.
633              
634             =cut
635              
636             =head2 calc_webhook_signature($shared_secret, $request_body)
637              
638             Calculates the webhook_signature based off the shared secret and request body passed in.
639              
640             =cut
641              
642             =head2 verify_webhook($shared_secret, $request_body)
643              
644             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.
645              
646             Follows this: http://wiki.shopify.com/Verifying_Webhooks.
647              
648             =cut
649              
650             use Exporter 'import';
651             our @EXPORT_OK = qw(verify_webhook verify_login verify_proxy calc_webhook_signature calc_login_signature calc_proxy_signature handleize);
652             use Digest::MD5 'md5_hex';
653             use Digest::SHA qw(hmac_sha256_hex hmac_sha256_base64);
654             use MIME::Base64;
655              
656             sub calc_webhook_signature {
657             my ($shared_secret, $request_body) = @_;
658             my $calc_signature = hmac_sha256_base64((defined $request_body) ? $request_body : "", $shared_secret);
659             while (length($calc_signature) % 4) { $calc_signature .= '='; }
660             return $calc_signature;
661             }
662              
663             sub verify_webhook {
664             my ($x_shopify_hmac_sha256, $request_body, $shared_secret) = @_;
665             return undef unless $x_shopify_hmac_sha256;
666             return $x_shopify_hmac_sha256 eq calc_webhook_signature($shared_secret, $request_body);
667             }
668              
669             =head2 calc_login_signature($shared_secret, $%params)
670              
671             Calculates the login signature based on the shared secret and parmaeter hash passed in.
672              
673             =cut
674              
675             =head2 verify_login($shared_secret, $%params)
676              
677             Shopify app dashboard verification (when someone clicks Login on the app dashboard).
678              
679             This one was kinda random, 'cause they say it's like a webhook, but it's actually like legacy auth.
680              
681             Also, they don't have a code parameter. For whatever reason.
682              
683             =cut
684              
685             sub calc_login_signature {
686             my ($shared_secret, $params) = @_;
687             return md5_hex($shared_secret . join("", map { "$_=" . $params->{$_} } (sort(grep { $_ ne "signature" } keys(%$params)))));
688             }
689              
690             sub verify_login {
691             my ($shared_secret, $params) = @_;
692             return undef unless $params->{signature};
693             return calc_login_signature($shared_secret, $params) eq $params->{signature};
694             }
695              
696             =head2 calc_proxy_signature($shared_secret, $%params)
697              
698             Based on shared secret/hash of parameters passed in, calculates the proxy signature.
699              
700             =cut
701              
702             =head2 verify_proxy($shared_secret, %$params)
703              
704             This is SLIGHTLY different from the above two. For, as far as I can tell, no reason.
705              
706             =cut
707              
708             sub calc_proxy_signature {
709             my ($shared_secret, $params) = @_;
710             return hmac_sha256_hex(join("", sort(map {
711             my $p = $params->{$_};
712             "$_=" . (ref($p) eq "ARRAY" ? join("$_=", @$p) : $p);
713             } (grep { $_ ne "signature" } keys(%$params)))), $shared_secret);
714             }
715              
716             sub verify_proxy {
717             my ($shared_secret, $params) = @_;
718             return undef unless $params->{signature};
719             return calc_proxy_signature($shared_secret, $params) eq $params->{signature};
720             }
721              
722             =head1 SEE ALSO
723              
724             L, L, L, L, L
725              
726             =head1 AUTHOR
727              
728             Adam Harrison (adamdharrison@gmail.com)
729              
730             =head1 LICENSE
731              
732             Copyright (C) 2014 Adam Harrison
733              
734             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:
735              
736             The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
737              
738             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.
739              
740             =cut
741              
742             1;