Skip to content

Commit caa1fb6

Browse files
authored
Merge pull request #11846 from notbakaneko/feature/shopify-storefront-api
Update Shopify related checkouts to use Shopify Storefront API
2 parents 03e9852 + f572b20 commit caa1fb6

29 files changed

+961
-241
lines changed

.github/workflows/lint.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ jobs:
4949
- name: Install js dependencies
5050
run: yarn --frozen-lockfile
5151

52-
- run: 'yarn lint --max-warnings 82 > /dev/null'
52+
- run: 'yarn lint --max-warnings 57 > /dev/null'
5353

5454
- run: yarn pretty
5555

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4+
// See the LICENCE file in the repository root for full licence text.
5+
6+
declare(strict_types=1);
7+
8+
namespace App\Console\Commands;
9+
10+
use App\Libraries\Store\ShopifyOrder;
11+
use App\Models\Store\Order;
12+
use Illuminate\Console\Command;
13+
use Shopify\Utils;
14+
15+
class StoreGetShopifyOrder extends Command
16+
{
17+
protected $description = 'Gets order info from shopify.';
18+
protected $signature = 'store:get-shopify-order {orderId} {--u|update : Updates the existing Order if possible}';
19+
20+
private ShopifyOrder $order;
21+
22+
public function handle()
23+
{
24+
$order = Order::findOrFail(get_int($this->argument('orderId')));
25+
if ($order->provider !== Order::PROVIDER_SHOPIFY) {
26+
$this->error('Not a Shopify order');
27+
return static::INVALID;
28+
}
29+
30+
$this->info("Getting details for Order {$order->getKey()}");
31+
32+
$gid = $order->reference;
33+
if (!isset(Utils::getQueryParams($gid)['key'])) {
34+
$this->error('Missing key param in id for querying');
35+
return static::INVALID;
36+
}
37+
38+
$this->warn('The id and statusUrl returned are private and should not be shared!');
39+
40+
$this->order = new ShopifyOrder($order);
41+
42+
$query = $this->makeQuery($gid);
43+
if ($query === null) {
44+
$this->error('Not a supported Shopify ID for querying.');
45+
return static::INVALID;
46+
}
47+
48+
$client = ShopifyOrder::storefontClient('unauthenticated_read_checkouts,unauthenticated_read_customers');
49+
50+
$body = $client->query($query)->getDecodedBody() ?? '';
51+
$this->line(is_array($body) ? json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : $body);
52+
53+
if (isset($body['errors'])) {
54+
return static::FAILURE;
55+
}
56+
57+
if ($this->option('update')) {
58+
$this->comment('Updating Order with Shopify details...');
59+
$orderNode = $this->order->gidType === 'Order' ? $body['data']['node'] : $body['data']['node']['order'] ?? null;
60+
if ($orderNode === null) {
61+
$this->error('Missing order node in response.');
62+
return static::FAILURE;
63+
}
64+
65+
$this->order->updateOrderWithGql($orderNode);
66+
}
67+
}
68+
69+
private function makeQuery(string $gid): ?string
70+
{
71+
$id = json_encode($gid, JSON_UNESCAPED_SLASHES);
72+
73+
return match ($this->order->gidType) {
74+
'Cart' => <<<QUERY
75+
{
76+
cart(id: $id) {
77+
createdAt
78+
id
79+
checkoutUrl
80+
}
81+
}
82+
QUERY,
83+
84+
'Checkout' => <<<QUERY
85+
{
86+
node(id: $id) {
87+
... on Checkout {
88+
completedAt
89+
id
90+
ready
91+
webUrl
92+
updatedAt
93+
order {
94+
canceledAt
95+
financialStatus
96+
fulfillmentStatus
97+
id
98+
orderNumber
99+
processedAt
100+
statusUrl
101+
billingAddress {
102+
countryCodeV2
103+
}
104+
}
105+
}
106+
}
107+
}
108+
QUERY,
109+
110+
'Order' => <<<QUERY
111+
{
112+
node(id: $id) {
113+
... on Order {
114+
canceledAt
115+
financialStatus
116+
fulfillmentStatus
117+
id
118+
orderNumber
119+
processedAt
120+
statusUrl
121+
billingAddress {
122+
countryCodeV2
123+
}
124+
}
125+
}
126+
}
127+
QUERY,
128+
129+
default => null,
130+
};
131+
}
132+
}

app/Console/Commands/StoreGetShopifyCheckout.php app/Console/Commands/StoreListShopifyProducts.php

+20-32
Original file line numberDiff line numberDiff line change
@@ -7,69 +7,57 @@
77

88
namespace App\Console\Commands;
99

10-
use App\Models\Store\Order;
1110
use Illuminate\Console\Command;
1211
use Shopify\ApiVersion;
1312
use Shopify\Auth\FileSessionStorage;
1413
use Shopify\Clients\Storefront;
1514
use Shopify\Context;
1615

17-
class StoreGetShopifyCheckout extends Command
16+
class StoreListShopifyProducts extends Command
1817
{
19-
protected $signature = 'store:get-shopify-checkout {orderId}';
20-
21-
protected $description = 'Gets checkout info from shopify.';
18+
protected $description = 'List Products from Shopify';
19+
protected $signature = 'store:list-shopify-products';
2220

2321
public function handle()
2422
{
25-
$order = Order::findOrFail(get_int($this->argument('orderId')));
26-
if ($order->provider !== 'shopify') {
27-
$this->error('Not a Shopify order');
28-
return static::INVALID;
29-
}
30-
31-
$this->comment("Getting details for Order {$order->getKey()}");
32-
$this->comment($order->reference);
33-
3423
Context::initialize(
3524
// public unauthenticated Storefront API doesn't need OAuth and we can't use blanks.
3625
'unauthenticated_only',
3726
'unauthenticated_only',
38-
'unauthenticated_read_checkouts',
27+
'unauthenticated_read_product_listings',
3928
$GLOBALS['cfg']['store']['shopify']['domain'],
4029
new FileSessionStorage(),
41-
ApiVersion::APRIL_2023,
30+
ApiVersion::APRIL_2024,
4231
);
4332

4433
$client = new Storefront(
4534
$GLOBALS['cfg']['store']['shopify']['domain'],
4635
$GLOBALS['cfg']['store']['shopify']['storefront_token'],
4736
);
4837

49-
$id = '"'.$order->reference.'"';
38+
// just hope we never have more than 100 products or variants.
5039
$query = <<<QUERY
51-
{
52-
node(id: $id) {
53-
... on Checkout {
40+
{
41+
products(first: 100, sortKey: ID) {
42+
edges {
43+
node {
5444
id
55-
ready
56-
webUrl
57-
orderStatusUrl
58-
completedAt
59-
createdAt
60-
updatedAt
61-
order {
62-
id
63-
processedAt
64-
orderNumber
45+
title
46+
variants(first: 100, sortKey: ID) {
47+
edges {
48+
node {
49+
id
50+
title
51+
}
52+
}
6553
}
6654
}
6755
}
6856
}
57+
}
6958
QUERY;
7059

71-
$response = $client->query($query);
72-
$body = $response->getDecodedBody() ?? '';
60+
$body = $client->query($query)->getDecodedBody() ?? '';
7361
$this->line(is_array($body) ? json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : $body);
7462
}
7563
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
<?php
2+
3+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4+
// See the LICENCE file in the repository root for full licence text.
5+
6+
declare(strict_types=1);
7+
8+
namespace App\Console\Commands;
9+
10+
use App\Libraries\Store\ShopifyOrder;
11+
use App\Models\Store\Order;
12+
use Illuminate\Console\Command;
13+
use Illuminate\Support\Collection;
14+
use Shopify\Clients\Storefront;
15+
use Symfony\Component\Console\Helper\ProgressBar;
16+
17+
class StoreMigrateShopifyCheckouts extends Command
18+
{
19+
private const PROGRESS_BARS = [
20+
'orders' => 'Orders read',
21+
'gids' => 'Response nodes processed',
22+
'updated' => 'Orders updated',
23+
];
24+
25+
protected $signature = 'store:migrate-shopify-checkouts';
26+
27+
protected $description = 'Migrates Shopify orders using Checkout ids to the Order or Cart ids.';
28+
29+
private Storefront $client;
30+
/** @var ProgressBar[] */
31+
private array $progress;
32+
33+
private static function getOrderIdFromNode(array $node): ?int
34+
{
35+
// array of name-value pairs.
36+
$attributes = $node['customAttributes'];
37+
38+
foreach ($attributes as $attribute) {
39+
if ($attribute['key'] === 'orderId') {
40+
return get_int($attribute['value']);
41+
}
42+
}
43+
44+
return null;
45+
}
46+
47+
public function handle()
48+
{
49+
$this->client = ShopifyOrder::storefontClient('unauthenticated_read_checkouts');
50+
51+
/** @var \Symfony\Component\Console\Output\ConsoleOutput $output */
52+
$output = $this->output->getOutput();
53+
54+
foreach (static::PROGRESS_BARS as $name => $description) {
55+
$progress = new ProgressBar($output->section());
56+
$progress->setFormat("%current% {$description} | %message%");
57+
$progress->setMessage('');
58+
$progress->start();
59+
60+
$this->progress[$name] = $progress;
61+
}
62+
63+
$result = Order::where('provider', 'shopify')->whereNotNull('reference')->chunkById(1000, function (Collection $chunk) {
64+
$ordersById = $chunk->map(fn (Order $order) => new ShopifyOrder($order))->keyBy('order.order_id');
65+
$this->progress['orders']->advance(count($ordersById));
66+
67+
$ids = $ordersById->values()->map(fn (ShopifyOrder $order) => $order->getCheckoutId())->filter();
68+
$idChunks = $ids->chunk(100);
69+
70+
foreach ($idChunks as $idChunk) {
71+
// values() because laravel uses preserve_keys: true ...
72+
$body = $this->queryCheckoutIds($idChunk->values());
73+
74+
$errors = $body['errors'] ?? null;
75+
76+
if ($errors !== null) {
77+
$this->error($this->printableResponse($errors));
78+
79+
foreach ($this->progress as $progress) {
80+
$progress->display();
81+
}
82+
83+
return false;
84+
}
85+
86+
$nodes = $body['data']['nodes'];
87+
foreach ($nodes as $node) {
88+
$this->progress['gids']->advance();
89+
$this->progress['gids']->setMessage($node['id'] ?? 'null');
90+
// nodes appear to be returned in order of values queried, including nulls set for not found
91+
if ($node !== null) {
92+
$orderId = static::getOrderIdFromNode($node);
93+
if ($orderId !== null) {
94+
$order = $ordersById[$orderId];
95+
if (isset($node['order'])) {
96+
$order->updateOrderWithGql($node['order']);
97+
} else {
98+
// orders that haven't completed checkout.
99+
$order->order->update(['shopify_url' => $node['webUrl']]);
100+
}
101+
102+
$this->progress['updated']->advance();
103+
$this->progress['updated']->setMessage((string) $orderId);
104+
}
105+
}
106+
}
107+
108+
sleep(1);
109+
}
110+
});
111+
112+
if (!$result) {
113+
return static::FAILURE;
114+
}
115+
116+
foreach ($this->progress as $progress) {
117+
$progress->display();
118+
$progress->finish();
119+
}
120+
}
121+
122+
/**
123+
* Query will return a completely misleading error if there are ids that don't match the node type.
124+
*/
125+
private function queryCheckoutIds(Collection $ids)
126+
{
127+
$query = <<<QUERY
128+
{
129+
nodes(ids: {$ids->toJson(JSON_UNESCAPED_SLASHES)}) {
130+
... on Checkout {
131+
id
132+
webUrl
133+
customAttributes {
134+
key
135+
value
136+
}
137+
order {
138+
canceledAt
139+
financialStatus
140+
fulfillmentStatus
141+
id
142+
orderNumber
143+
processedAt
144+
statusUrl
145+
billingAddress {
146+
countryCodeV2
147+
}
148+
}
149+
}
150+
}
151+
}
152+
QUERY;
153+
154+
return $this->client->query($query)->getDecodedBody();
155+
}
156+
157+
private function printableResponse($value)
158+
{
159+
return is_array($value) ? json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : $value;
160+
}
161+
}

0 commit comments

Comments
 (0)