├── LICENSE ├── README.md ├── composer.json └── src └── Shopify.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Luke Towers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | A simple PHP wrapper around the [Shopify API](https://help.shopify.com/api/getting-started). 4 | 5 | ## Installation 6 | 7 | Install via [Composer](https://getcomposer.org/) by running `composer require luketowers/php-shopify-api` in your project directory. 8 | 9 | ## Usage 10 | 11 | In order to use this wrapper library you will need to provide credentials to access Shopify's API. 12 | 13 | You will either need an access token for the shop you are trying to access (if using a [public application](https://help.shopify.com/api/getting-started/authentication#public-applications)) or an API Key and Secret for a [private application](https://help.shopify.com/api/getting-started/authentication#private-applications). 14 | 15 | ## Examples 16 | 17 | #### Make an API call 18 | ```php 19 | use LukeTowers\ShopifyPHP\Shopify; 20 | 21 | // Initialize the client 22 | $api = new Shopify('exampleshop.myshopify.com', 'mysupersecrettoken'); 23 | 24 | // Get all products 25 | $result = $api->call('GET', 'admin/products.json'); 26 | 27 | // Get the products with ids of '632910392' and '921728736' with only the 'id', 'images', and 'title' fields 28 | $result = $api->call('GET', 'admin/products.json', [ 29 | 'ids' => '632910392,921728736', 30 | 'fields' => 'id,images,title', 31 | ]); 32 | 33 | // Create a new "Burton Custom Freestyle 151" product 34 | $result = $api->call('POST', 'admin/products.json', [ 35 | 'product' => [ 36 | "title" => "Burton Custom Freestyle 151", 37 | "body_html" => "Good snowboard!", 38 | "vendor" => "Burton", 39 | "product_type" => "Snowboard", 40 | "tags" => 'Barnes & Noble, John's Fav, "Big Air"', 41 | ], 42 | ]); 43 | ``` 44 | 45 | #### Use Private Application API Credentials to authenticate API requests 46 | ```php 47 | use LukeTowers\ShopifyPHP\Shopify; 48 | 49 | $api = new Shopify($data['shop'], [ 50 | 'api_key' => '...', 51 | 'secret' => '...', 52 | ]); 53 | ``` 54 | 55 | #### Use an access token to authenticate API requests 56 | ```php 57 | use LukeTowers\ShopifyPHP\Shopify; 58 | 59 | $storedToken = ''; // Retrieve the stored token for the shop in question 60 | $api = new Shopify('exampleshop.myshopify.com', $storedToken); 61 | ``` 62 | 63 | #### Request an access_token for a shop 64 | ```php 65 | use LukeTowers\ShopifyPHP\Shopify; 66 | 67 | function make_authorization_attempt($shop, $scopes) 68 | { 69 | $api = new Shopify($shop, [ 70 | 'api_key' => '...', 71 | 'secret' => '...', 72 | ]); 73 | 74 | $nonce = bin2hex(random_bytes(10)); 75 | 76 | // Store a record of the shop attempting to authenticate and the nonce provided 77 | $storedAttempts = file_get_contents('authattempts.json'); 78 | $storedAttempts = $storedAttempts ? json_decode($storedAttempts) : []; 79 | $storedAttempts[] = ['shop' => $shop, 'nonce' => $nonce, 'scopes' => $scopes]; 80 | file_put_contents('authattempts.json', json_encode($storedAttempts)); 81 | 82 | return $api->getAuthorizeUrl($scopes, 'https://example.com/handle/shopify/callback', $nonce); 83 | } 84 | 85 | header('Location: ' . make_authorization_attempt('exampleshop.myshopify.com', ['read_product'])); 86 | die(); 87 | ``` 88 | 89 | #### Handle Shopify's response to the authorization request 90 | ```php 91 | use LukeTowers\ShopifyPHP\Shopify; 92 | 93 | function check_authorization_attempt() 94 | { 95 | $data = $_GET; 96 | 97 | $api = new Shopify($data['shop'], [ 98 | 'api_key' => '...', 99 | 'secret' => '...', 100 | ]); 101 | 102 | $storedAttempt = null; 103 | $attempts = json_decode(file_get_contents('authattempts.json')); 104 | foreach ($attempts as $attempt) { 105 | if ($attempt->shop === $data['shop']) { 106 | $storedAttempt = $attempt; 107 | break; 108 | } 109 | } 110 | 111 | return $api->authorizeApplication($storedAttempt->nonce, $data); 112 | } 113 | 114 | $response = check_authorization_attempt(); 115 | if ($response) { 116 | // Store the access token for later use 117 | $response->access_token; 118 | } 119 | ``` 120 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luketowers/php-shopify-api", 3 | "description": "PHP wrapper for Shopify API", 4 | "keywords": ["PHP","Shopify","API"], 5 | "homepage": "https://github.com/luketowers/php-shopify-api", 6 | "type": "library", 7 | "require": { 8 | "php": ">=7.0", 9 | "guzzlehttp/guzzle": "~6.0|~7.0" 10 | }, 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Luke Towers", 15 | "email": "github@luketowers.ca" 16 | } 17 | ], 18 | "autoload": { 19 | "psr-4": { 20 | "LukeTowers\\ShopifyPHP\\": "src/" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Shopify.php: -------------------------------------------------------------------------------- 1 | '', 'secret' => ''] The Shopify API credentials for your application 43 | * @return null 44 | */ 45 | public function __construct(string $shopDomain, $credentials) { 46 | $this->shop_domain = $shopDomain; 47 | 48 | // Populate the credentials 49 | if (is_string($credentials)) { 50 | $this->setToken($credentials); 51 | } elseif (!empty($credentials['api_key']) && !empty($credentials['secret'])) { 52 | $this->api_key = $credentials['api_key']; 53 | $this->secret = $credentials['secret']; 54 | } else { 55 | throw new Exception("Unexpected value provided for the credentials"); 56 | } 57 | 58 | // Initialize the client 59 | $this->initializeClient(); 60 | } 61 | 62 | /** 63 | * Initialize the GuzzleHttp/Client instance 64 | * 65 | * @return GuzzleHttp/Client $client 66 | */ 67 | protected function initializeClient() 68 | { 69 | if ($this->client) { 70 | return $this->client; 71 | } 72 | 73 | $options = [ 74 | 'base_uri' => "https://{$this->shop_domain}/", 75 | 'http_errors' => true, 76 | ]; 77 | if (!empty($this->token)) { 78 | $options['headers']['X-Shopify-Access-Token'] = $this->token; 79 | } 80 | 81 | return $this->client = new Client($options); 82 | } 83 | 84 | /** 85 | * Set the token to be used for future requests 86 | * 87 | * @param string $token The token to use for future requests 88 | * @return null 89 | */ 90 | public function setToken(string $token) 91 | { 92 | $this->token = $token; 93 | // Reset the client 94 | unset($this->client); 95 | $this->client = null; 96 | $this->initializeClient(); 97 | } 98 | 99 | /** 100 | * Get the URL required to request authorization 101 | * 102 | * @param mixed $scopes The scopes to request access to 103 | * @param string $redirectUrl The URL to redirect to on successfull authorization 104 | * @param string $nonce The security token to pass to Shopify to validate the authorization request callback received from Shopify 105 | * @param bool $onlineAccessMode Request an Online Access Mode (user-level) token instead of a Offline Access Mode (shop-level). Default: false 106 | * @return string $url The Authorization URL 107 | */ 108 | public function getAuthorizeUrl($scopes, string $redirectUrl, string $nonce, $onlineAccessMode = false) 109 | { 110 | if (is_string($scopes)) { 111 | $scopes = [$scopes]; 112 | } 113 | 114 | $args = [ 115 | 'client_id' => $this->api_key, 116 | 'scope' => implode(',', $scopes), 117 | 'redirect_uri' => $redirectUrl, 118 | 'state' => $nonce, 119 | ]; 120 | 121 | if ($onlineAccessMode) { 122 | $args['grant_options[]'] = 'per-user'; 123 | } 124 | 125 | return "https://{$this->shop_domain}/admin/oauth/authorize?" . http_build_query($args); 126 | } 127 | 128 | /** 129 | * Authorize the application and return the data provided by Shopify 130 | * 131 | * @param string $nonce The nonce that was provided to Shopify in the initial authorization request 132 | * @param array $requestData The data that has been provided by Shopify in the callback. Expects 'code', 'hmac', 'state', 'shop', 'timestamp', but API subject to change 133 | * @return object $response Shopify's response to the access token request. See https://help.shopify.com/api/getting-started/authentication/oauth#step-3-confirm-installation 134 | */ 135 | public function authorizeApplication(string $nonce, $requestData) 136 | { 137 | $requiredKeys = ['code', 'hmac', 'state', 'shop']; 138 | foreach ($requiredKeys as $required) { 139 | if (!in_array($required, array_keys($requestData))) { 140 | throw new Exception("The provided request data is missing one of the following keys: " . implode(', ', $requiredKeys)); 141 | } 142 | } 143 | 144 | if ($requestData['state'] !== $nonce) { 145 | throw new Exception("The provided nonce ($nonce) did not match the nonce provided by Shopify ({$requestData['state']})"); 146 | } 147 | 148 | if (!filter_var($requestData['shop'], FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) { 149 | throw new Exception("The shop provided by Shopify ({$requestData['shop']}) is an invalid hostname."); 150 | } 151 | 152 | if ($requestData['shop'] !== $this->shop_domain) { 153 | throw new Exception("The shop provided by Shopify ({$requestData['shop']}) does not match the shop provided to this API ({$this->shop_domain})"); 154 | } 155 | 156 | // Check HMAC signature. See https://help.shopify.com/api/getting-started/authentication/oauth#verification 157 | $hmacSource = []; 158 | foreach ($requestData as $key => $value) { 159 | // Skip the hmac key 160 | if ($key === 'hmac') { continue; } 161 | 162 | // Replace the characters as specified by Shopify in the keys and values 163 | $valuePatterns = [ 164 | '&' => '%26', 165 | '%' => '%25', 166 | ]; 167 | $keyPatterns = array_merge($valuePatterns, ['=' => '%3D']); 168 | $key = str_replace(array_keys($keyPatterns), array_values($keyPatterns), $key); 169 | $value = str_replace(array_keys($valuePatterns), array_values($valuePatterns), $value); 170 | 171 | $hmacSource[] = $key . '=' . $value; 172 | } 173 | 174 | // Sort the key value pairs lexographically and then generate the HMAC signature of the provided data 175 | sort($hmacSource); 176 | $hmacBase = implode('&', $hmacSource); 177 | $hmacString = hash_hmac('sha256', $hmacBase, $this->secret); 178 | 179 | // Verify that the signatures match 180 | if ($hmacString !== $requestData['hmac']) { 181 | throw new Exception("The HMAC provided by Shopify ({$requestData['hmac']}) doesn't match the HMAC verification ($hmacString)."); 182 | } 183 | 184 | // Make the access token request to Shopify 185 | try { 186 | $response = $this->client->request('POST', 'admin/oauth/access_token', [ 187 | 'body' => json_encode([ 188 | 'client_id' => $this->api_key, 189 | 'client_secret' => $this->secret, 190 | 'code' => $requestData['code'], 191 | ]), 192 | 'headers' => [ 193 | 'Content-Type' => 'application/json', 194 | ], 195 | ]); 196 | } catch (Exception $e) { 197 | // Pass the erroring response direct to browser 198 | die($e->getResponse()->getBody()); 199 | } 200 | 201 | // Decode the response from Shopify 202 | $data = json_decode($response->getBody()); 203 | 204 | // Set the access token 205 | $this->setToken($data->access_token); 206 | 207 | // Return the result of the authorization attempt 208 | return $data; 209 | } 210 | 211 | /** 212 | * Make an API call to Shopify 213 | * 214 | * @param string $method The method to use to call Shopify 215 | * @param string $endpoint The endpoint to access on Shopify 216 | * @param array $params The parameters to provide in the API request 217 | * @return mixed $response 218 | */ 219 | public function call(string $method, string $endpoint, $params = []) 220 | { 221 | $method = strtoupper($method); 222 | $options = []; 223 | 224 | // Use the API credentials to authenticate as a private application 225 | if (empty($this->token)) { 226 | $options['headers']['Authorization'] = 'Basic ' . base64_encode($this->api_key . ':' . $this->secret); 227 | } 228 | 229 | // Prepare the request based on the method used 230 | switch ($method) { 231 | case 'GET': 232 | case 'DELETE': 233 | $options['query'] = $params; 234 | break; 235 | 236 | case 'PUT': 237 | case 'POST': 238 | $options['body'] = json_encode($params); 239 | $options['headers']['Content-Type'] = 'application/json'; 240 | break; 241 | } 242 | 243 | // Make the request 244 | $response = $this->client->request($method, $endpoint, $options); 245 | 246 | // Store the response headers for later usage 247 | $this->last_response_headers = $response->getHeaders(); 248 | 249 | // Return the response body 250 | return json_decode($response->getBody()); 251 | } 252 | 253 | /** 254 | * Get the number of calls that have been made since the bucket was empty 255 | * 256 | * @return int $calls 257 | */ 258 | public function getCallsMade() 259 | { 260 | return $this->getCallLimitHeaderValue()[0]; 261 | } 262 | 263 | /** 264 | * Get the number of calls that the bucket can support 265 | * 266 | * @return int $calls 267 | */ 268 | public function getCallLimit() 269 | { 270 | return $this->getCallLimitHeaderValue()[1]; 271 | } 272 | 273 | /** 274 | * Get the number of calls remaining before the bucket is full 275 | * 276 | * @return int $calls 277 | */ 278 | public function getCallsRemaining() 279 | { 280 | return $this->getCallLimit() - $this->getCallsMade(); 281 | } 282 | 283 | /** 284 | * Get the call amount information from the last API request 285 | * 286 | * @return array $calls [$callsMade, $callsRemaining] 287 | */ 288 | protected function getCallLimitHeaderValue() 289 | { 290 | if (!$this->last_response_headers) { 291 | throw new Exception("Call limits can't be polled before a request has been made."); 292 | } 293 | 294 | return explode('/', $this->last_response_headers['X-Shopify-Shop-Api-Call-Limit'][0]); 295 | } 296 | } 297 | --------------------------------------------------------------------------------