├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── lib └── shopify.js ├── package.json ├── shopify-node-api.d.ts └── test └── shopify.js /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | npm-debug.log 15 | node_modules 16 | .idea/ 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | - "6" 5 | - "5" 6 | - "4" 7 | - "0.12" 8 | - "0.11" 9 | - "0.10" 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 sinechris 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. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | ./node_modules/.bin/mocha --reporter spec 3 | 4 | .PHONY: test 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | shopify-node-api [![Build Status](https://travis-ci.org/christophergregory/shopify-node-api.svg?branch=master)](https://travis-ci.org/christophergregory/shopify-node-api) 2 | ================ 3 | 4 | OAuth2 Module for Shopify API 5 | 6 | [![NPM](https://nodei.co/npm/shopify-node-api.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/shopify-node-api/) 7 | 8 | ## Install 9 | ``` 10 | npm install -S shopify-node-api 11 | ``` 12 | 13 | ## Configure Public App 14 | 15 | Public apps are apps intended to appear in the [Shopify App Store](https://apps.shopify.com?ref=grenadeapps) and require OAuth2 to access shop data. 16 | 17 | ```js 18 | var shopifyAPI = require('shopify-node-api'); 19 | 20 | 21 | var Shopify = new shopifyAPI({ 22 | shop: 'MYSHOP', // MYSHOP.myshopify.com 23 | shopify_api_key: '', // Your API key 24 | shopify_shared_secret: '', // Your Shared Secret 25 | shopify_scope: 'write_products', 26 | redirect_uri: 'http://localhost:3000/finish_auth', 27 | nonce: '' // you must provide a randomly selected value unique for each authorization request 28 | }); 29 | 30 | ``` 31 | 32 | 33 | ## Configure Private App 34 | 35 | Private apps are created for a single shop and do not appear in the shopify app store. [More info here.](https://docs.shopify.com/api/guides/introduction/creating-a-private-app) 36 | 37 | ```js 38 | var shopifyAPI = require('shopify-node-api'); 39 | 40 | 41 | var Shopify = new shopifyAPI({ 42 | shop: 'MYSHOP', // MYSHOP.myshopify.com 43 | shopify_api_key: '', // Your API key 44 | access_token: '' // Your API password 45 | }); 46 | ``` 47 | 48 | Note: If you are building a [private Shopify app](https://docs.shopify.com/api/authentication/creating-a-private-app?ref=grenadeapps), then you don't need to go through the OAuth authentication process. You can skip ahead to the Making Requests section. 49 | 50 | 51 | ### CAUTION!!! 52 | 53 | If no config object is passed into the module upon initialization, an error will be thrown! 54 | ```js 55 | var Shopify = new shopifyAPI(); // No config object passed in 56 | ``` 57 | 58 | will throw an error like: 59 | 60 | ~~~ 61 | > Error: ShopifyAPI module expects a config object 62 | > Please see documentation at: https://github.com/sinechris/shopify-node-api 63 | ~~~ 64 | 65 | ## Usage 66 | 67 | ```js 68 | 69 | // Building the authentication url 70 | 71 | var auth_url = Shopify.buildAuthURL(); 72 | 73 | // Assuming you are using the express framework 74 | // you can redirect the user automatically like so 75 | res.redirect(auth_url); 76 | ``` 77 | 78 | 79 | ## Exchanging the temporary token for a permanent one 80 | 81 | After the user visits the authenticaion url they will be redirected to the location you specified in the configuration redirect_url parameter. 82 | 83 | Shopify will send along some query parameters including: code (your temporary token), signature, shop, state and timestamp. This module will verify the authenticity of the request from shopify as outlined here in the [Shopify OAuth Docs](http://docs.shopify.com/api/tutorials/oauth?ref=grenadeapps) 84 | 85 | ```js 86 | 87 | // Again assuming you are using the express framework 88 | 89 | app.get('/finish_auth', function(req, res){ 90 | 91 | var Shopify = new shopifyAPI(config), // You need to pass in your config here 92 | query_params = req.query; 93 | 94 | Shopify.exchange_temporary_token(query_params, function(err, data){ 95 | // This will return successful if the request was authentic from Shopify 96 | // Otherwise err will be non-null. 97 | // The module will automatically update your config with the new access token 98 | // It is also available here as data['access_token'] 99 | }); 100 | 101 | }); 102 | 103 | ``` 104 | 105 | ### Note: 106 | 107 | Once you have initially received your access token you can instantiate a new instance at a later date like so: 108 | 109 | ```js 110 | var Shopify = new shopifyAPI({ 111 | shop: 'MYSHOP', // MYSHOP.myshopify.com 112 | shopify_api_key: '', // Your API key 113 | shopify_shared_secret: '', // Your Shared Secret 114 | access_token: 'token', //permanent token 115 | }); 116 | 117 | ``` 118 | 119 | 120 | 121 | ## Making requests 122 | 123 | This module supports GET, POST, PUT and DELETE rest verbs. Each request will return any errors, the data in JSON formation and any headers returned by the request. 124 | 125 | An important header to take note of is **'http_x_shopify_shop_api_call_limit'**. This will let you know if you are getting close to reaching [Shopify's API call limit](http://docs.shopify.com/api/tutorials/learning-to-respect-the-api-call-limit). 126 | 127 | ### API limits 128 | 129 | ```js 130 | function callback(err, data, headers) { 131 | var api_limit = headers['http_x_shopify_shop_api_call_limit']; 132 | console.log( api_limit ); // "1/40" 133 | } 134 | ``` 135 | 136 | ### GET 137 | 138 | ```js 139 | Shopify.get('/admin/products.json', query_data, function(err, data, headers){ 140 | console.log(data); // Data contains product json information 141 | console.log(headers); // Headers returned from request 142 | }); 143 | ``` 144 | The argument `query_data` is optional. If included it will be converted to a querystring and appended to the uri. 145 | 146 | ### POST 147 | 148 | ```js 149 | var post_data = { 150 | "product": { 151 | "title": "Burton Custom Freestlye 151", 152 | "body_html": "Good snowboard!", 153 | "vendor": "Burton", 154 | "product_type": "Snowboard", 155 | "variants": [ 156 | { 157 | "option1": "First", 158 | "price": "10.00", 159 | "sku": 123 160 | }, 161 | { 162 | "option1": "Second", 163 | "price": "20.00", 164 | "sku": "123" 165 | } 166 | ] 167 | } 168 | } 169 | 170 | Shopify.post('/admin/products.json', post_data, function(err, data, headers){ 171 | console.log(data); 172 | }); 173 | ``` 174 | 175 | ### PUT 176 | 177 | ```js 178 | var put_data = { 179 | "product": { 180 | "body_html": "Updated!" 181 | } 182 | } 183 | 184 | Shopify.put('/admin/products/1234567.json', put_data, function(err, data, headers){ 185 | console.log(data); 186 | }); 187 | ``` 188 | 189 | ### DELETE 190 | 191 | ```js 192 | Shopify.delete('/admin/products/1234567.json', function(err, data, headers){ 193 | console.log(data); 194 | }); 195 | ``` 196 | 197 | ## Errors 198 | 199 | Every response from Shopify's API is parsed and checked if it looks like an error. Three keys are used to determine an error response: 'error_description', 'error', and 'errors'. If any of these keys are found in the response, an error object will be made with the first found key's value as the error message and the response's status code as the error's code. This error object will be passed as the first parameter in the callback, along with the response JSON and response headers. 200 | 201 | If an error occurs while making a request, the callback will be passed an error object provided from `https` as the only parameter. 202 | 203 | ## OPTIONS 204 | 205 | 206 | ### Verbose Mode 207 | 208 | By default, shopify-node-api will automatically console.log all headers and responses. To suppress these messages, simply set verbose to false. 209 | 210 | ```js 211 | var config = { 212 | ... 213 | verbose: false 214 | } 215 | ``` 216 | 217 | ##### Additional Verbose Options 218 | 219 | If only a particular message type(s) is desired it may be specifically requested 220 | to override the standard verbose console logging. 221 | 222 | Available logging options: 223 | * verbose_status 224 | * verbose_headers 225 | * verbose_api_limit 226 | * verbose_body 227 | 228 | ```js 229 | var config = { 230 | ... 231 | verbose_headers: true, 232 | verbose_api_limit: true 233 | } 234 | ``` 235 | 236 | The above config results in only messages beginning as type __HEADER:__ and 237 | __API_LIMIT:__ to be logged. 238 | 239 | This is a more ideal use case for a production server, where excessive 240 | body content logging may obstruct developers from isolating meaningful server 241 | data. 242 | 243 | ### Verify Shopify Request 244 | 245 | **Note**: *This module has been updated to use HMAC parameter instead of the deprecated "signature"*. 246 | 247 | From the [shopify docs](http://docs.shopify.com/api/tutorials/oauth?ref=grenadeapps): 248 | 249 | "Every request or redirect from Shopify to the client server includes a signature and hmac parameters that can be used to ensure that it came from Shopify. **The signature attribute is deprecated due to vulnerabilities in how the signature is generated.**" 250 | 251 | The module utilizes the *is_valid_signature* function to verify that requests coming from shopify are authentic. You can use this method in your code to verify requests from Shopify. Here is an example of its use in the this module: 252 | 253 | ```js 254 | ShopifyAPI.prototype.exchange_temporary_token = function(query_params, callback) { 255 | 256 | // Return an error if signature is not valid 257 | if (!self.is_valid_signature(query_params)) { 258 | return callback(new Error("Signature is not authentic!")); 259 | } 260 | 261 | // do more things... 262 | } 263 | ``` 264 | 265 | You can call it from an initialized Shopify object like so 266 | 267 | ```js 268 | Shopify.is_valid_signature(query_params); 269 | ``` 270 | 271 | To verify a Shopify signature that does not contain a state parameter, just pass true as the second argument of `is_valid_signature`: 272 | 273 | ```js 274 | Shopify.is_valid_signature(query_params, true); 275 | ``` 276 | *This is required when checking a non-authorization query string, for example the query string passed when the app is clicked in the user's app store* 277 | 278 | ### API Call Limit Options 279 | 280 | By default, shopify-node-api will automatically wait if you approach Shopify's API call limit. The default setting for backoff delay time is 1 second if you reach 35 out of 40 calls. If you hit the limit, Shopify will return a 429 error, and by default, this module will have a rate limit delay time of 10 seconds. You can modify these options using the following parameters: 281 | 282 | ```js 283 | var config = { 284 | //... 285 | rate_limit_delay: 10000, // 10 seconds (in ms) => if Shopify returns 429 response code 286 | backoff: 35, // limit X of 40 API calls => default is 35 of 40 API calls 287 | backoff_delay: 1000 // 1 second (in ms) => wait 1 second if backoff option is exceeded 288 | } 289 | ``` 290 | 291 | Alternatively if you are working on a Shopify Plus or Gold project or if you get increased API limits from your Shopify rep you can use 'backoff_level' to specify at what fraction of bucket capacity your app should start backing off. 292 | 293 | ```js 294 | var config = { 295 | //... 296 | rate_limit_delay: 10000, // 10 seconds (in ms) => if Shopify returns 429 response code 297 | backoff_level: 0.85, // limit X/bucket size API calls => no default since 'backoff' is the default behaviour 298 | backoff_delay: 1000 // 1 second (in ms) => wait 1 second if backoff option is exceeded 299 | } 300 | ``` 301 | # Become a Shopify App Developer 302 | 303 | [Join the Shopify Partner Program](https://app.shopify.com/services/partners/signup?ref=grenadeapps) 304 | 305 | # Testing 306 | 307 | ~~~ 308 | npm install 309 | npm test 310 | ~~~ 311 | 312 | 313 | # Contributing 314 | 315 | Shopify has been kind enough to list this module on their [Official Documentation](http://docs.shopify.com/api/libraries/node?ref=grenadeapps). As such it is important that this module remain as bug free and up to date as possible in order to make the experience with node.js/Shopify as seamless as possible. 316 | 317 | I will continue to make updates as often as possible but we are more than happy to review any feature requests and will be accepting pull requests as well. 318 | -------------------------------------------------------------------------------- /lib/shopify.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Shopify OAuth2 node.js API 3 | * 4 | * 5 | * 6 | */ 7 | 8 | var crypto = require('crypto'); 9 | var BigJSON = require('json-bigint'); 10 | var querystring = require('querystring'); 11 | var zlib = require('zlib'); 12 | 13 | function ShopifyAPI(config) { 14 | 15 | if (!(this instanceof ShopifyAPI)) return new ShopifyAPI(config); 16 | 17 | if (config == null) { // == checks for null and undefined 18 | var msg = "ShopifyAPI module expects a config object\nPlease see documentation at: https://github.com/sinechris/shopify-node-api\n"; 19 | throw new Error(msg); 20 | } 21 | 22 | this.config = config; 23 | 24 | if(this.config.backoff_level){ 25 | this.config.backoff_level = parseFloat(this.config.backoff_level); 26 | } 27 | 28 | if (this.config.verbose !== false){ 29 | this.config.verbose = true; 30 | } 31 | 32 | // If any condition below is true assume the user does not want all logging 33 | if (this.config.verbose_status === true){ 34 | this.config.verbose = false; 35 | } 36 | if (this.config.verbose_headers === true){ 37 | this.config.verbose = false; 38 | } 39 | if (this.config.verbose_api_limit === true){ 40 | this.config.verbose = false; 41 | } 42 | if (this.config.verbose_body === true){ 43 | this.config.verbose = false; 44 | } 45 | } 46 | 47 | ShopifyAPI.prototype.buildAuthURL = function(){ 48 | var auth_url = 'https://' + this.config.shop.split(".")[0]; 49 | auth_url += ".myshopify.com/admin/oauth/authorize?"; 50 | auth_url += "client_id=" + this.config.shopify_api_key; 51 | auth_url += "&scope=" + this.config.shopify_scope; 52 | auth_url += "&redirect_uri=" + this.config.redirect_uri; 53 | auth_url += "&state=" + this.config.nonce; 54 | return auth_url; 55 | }; 56 | 57 | ShopifyAPI.prototype.set_access_token = function(token) { 58 | this.config.access_token = token; 59 | }; 60 | 61 | ShopifyAPI.prototype.conditional_console_log = function(msg) { 62 | if (this.config.verbose) { 63 | console.log( msg ); 64 | } 65 | // If any message type condition below is met show that message 66 | else { 67 | if (this.config.verbose_status === true && /^STATUS:/.test(msg) ){ 68 | console.log( msg ); 69 | } 70 | if (this.config.verbose_headers === true && /^HEADERS:/.test(msg)){ 71 | console.log( msg ); 72 | } 73 | if (this.config.verbose_api_limit === true && /^API_LIMIT:/.test(msg)){ 74 | console.log( msg ); 75 | } 76 | if (this.config.verbose_body === true && /^BODY:/.test(msg)){ 77 | console.log( msg ); 78 | } 79 | } 80 | }; 81 | 82 | ShopifyAPI.prototype.is_valid_signature = function(params, non_state) { 83 | if(!non_state && this.config.nonce !== params['state']){ 84 | return false; 85 | } 86 | 87 | var hmac = params['hmac'], 88 | theHash = params['hmac'] || params['signature'], 89 | secret = this.config.shopify_shared_secret, 90 | parameters = [], 91 | digest, 92 | message; 93 | 94 | for (var key in params) { 95 | if (key !== "hmac" && key !== "signature") { 96 | parameters.push(key + '=' + params[key]); 97 | } 98 | } 99 | 100 | message = parameters.sort().join(hmac ? '&' : ''); 101 | 102 | digest = crypto 103 | .createHmac('SHA256', secret) 104 | .update(message) 105 | .digest('hex'); 106 | 107 | return ( digest === theHash ); 108 | }; 109 | 110 | ShopifyAPI.prototype.exchange_temporary_token = function(query_params, callback) { 111 | var data = { 112 | client_id: this.config.shopify_api_key, 113 | client_secret: this.config.shopify_shared_secret, 114 | code: query_params['code'] 115 | }, 116 | self = this; 117 | 118 | if (!self.is_valid_signature(query_params)) { 119 | return callback(new Error("Signature is not authentic!")); 120 | } 121 | 122 | this.makeRequest('/admin/oauth/access_token', 'POST', data, function(err, body){ 123 | 124 | if (err) { 125 | // err is either already an Error or it is a JSON object with an 126 | // error field. 127 | if (err.error) return callback(new Error(err.error)); 128 | return callback(err); 129 | } 130 | 131 | self.set_access_token(body['access_token']); 132 | callback(null, body); 133 | 134 | }); 135 | }; 136 | 137 | ShopifyAPI.prototype.hostname = function () { 138 | return this.config.shop.split(".")[0] + '.myshopify.com'; 139 | }; 140 | 141 | ShopifyAPI.prototype.port = function () { 142 | return 443; 143 | }; 144 | 145 | ShopifyAPI.prototype.makeRequest = function(endpoint, method, data, callback, retry) { 146 | 147 | var https = require('https'), 148 | dataString = JSON.stringify(data), 149 | options = { 150 | hostname: this.hostname(), 151 | path: endpoint, 152 | method: method.toLowerCase() || 'get', 153 | port: this.port(), 154 | agent: this.config.agent, 155 | headers: { 156 | 'Content-Type': 'application/json', 157 | 'Accept': 'application/json', 158 | 'Accept-Encoding': 'gzip, deflate' 159 | } 160 | }, 161 | self = this; 162 | 163 | if (this.config.access_token) { 164 | options.headers['X-Shopify-Access-Token'] = this.config.access_token; 165 | } 166 | 167 | if (options.method === 'post' || options.method === 'put' || options.method === 'delete' || options.method === 'patch') { 168 | options.headers['Content-Length'] = new Buffer(dataString).length; 169 | } 170 | 171 | var request = https.request(options, function(response){ 172 | self.conditional_console_log( 'STATUS: ' + response.statusCode ); 173 | self.conditional_console_log( 'HEADERS: ' + JSON.stringify(response.headers) ); 174 | 175 | if (response.headers && response.headers.http_x_shopify_shop_api_call_limit) { 176 | self.conditional_console_log( 'API_LIMIT: ' + response.headers.http_x_shopify_shop_api_call_limit); 177 | } 178 | 179 | var contentEncoding = response.headers['content-encoding']; 180 | var shouldUnzip = ['gzip', 'deflate'].indexOf(contentEncoding) !== -1; 181 | var encoding = shouldUnzip && 'binary' || 'utf8'; 182 | var body = ''; 183 | 184 | response.setEncoding(encoding); 185 | 186 | response.on('data', function(chunk){ 187 | self.conditional_console_log( 'BODY: ' + chunk ); 188 | body += chunk; 189 | }); 190 | 191 | response.on('end', function(){ 192 | 193 | var delay = 0; 194 | 195 | // If the request is being rate limited by Shopify, try again after a delay 196 | if (response.statusCode === 429) { 197 | return setTimeout(function() { 198 | self.makeRequest(endpoint, method, data, callback); 199 | }, self.config.rate_limit_delay || 10000 ); 200 | } 201 | 202 | // If the backoff limit is reached, add a delay before executing callback function 203 | if ((response.statusCode >= 200 || response.statusCode <= 299) && self.has_header(response, 'http_x_shopify_shop_api_call_limit')) { 204 | var api_limit_parts = response.headers['http_x_shopify_shop_api_call_limit'].split('/'); 205 | 206 | var api_limit = parseInt(api_limit_parts[0], 10); 207 | var api_max = parseInt(api_limit_parts[1], 10); // 40 on standard shopify accounts 208 | var limit_rate = false; 209 | if(self.config.backoff_level){ 210 | var used_api = api_limit / api_max; 211 | limit_rate = (used_api > self.config.backoff_level); 212 | self.conditional_console_log('FRACTION_USED: '+ used_api +' of '+ self.config.backoff_level); 213 | }else limit_rate = (api_limit >= (self.config.backoff || 35)); 214 | 215 | if(limit_rate){ 216 | self.conditional_console_log('RATE DELAY: '+ api_limit +' of '+ api_max); 217 | delay = self.config.backoff_delay || 1000; // in ms 218 | } 219 | } 220 | 221 | setTimeout(function(){ 222 | 223 | var json = {} 224 | , error; 225 | 226 | function parseResponse(body) { 227 | try { 228 | if (body.trim() != '') { //on some requests, Shopify retuns an empty body (several spaces) 229 | json = BigJSON.parse(body); 230 | } 231 | } catch(e) { 232 | error = e; 233 | } 234 | 235 | if (response.statusCode >= 400) { 236 | if (json && (json.hasOwnProperty('error_description') || json.hasOwnProperty('error') || json.hasOwnProperty('errors'))) { 237 | var jsonError = (json.error_description || json.error || json.errors); 238 | } 239 | error = { 240 | code: response.statusCode 241 | , error: jsonError || response.statusMessage 242 | }; 243 | } 244 | 245 | return callback(error, json, response.headers, options); 246 | } 247 | 248 | // Use GZIP decompression if required 249 | if (shouldUnzip) { 250 | var unzip = contentEncoding === 'deflate' && zlib.deflate || zlib.gunzip; 251 | return unzip(new Buffer(body, 'binary'), function(err, data) { 252 | if (err) { 253 | return callback(err, null, response.headers, options); 254 | } 255 | return parseResponse(data.toString('utf8')); 256 | }); 257 | } 258 | 259 | return parseResponse(body); 260 | }, delay); // Delay the callback if we reached the backoff limit 261 | 262 | }); 263 | 264 | }); 265 | 266 | request.on('error', function(e){ 267 | self.conditional_console_log( "Request Error: ", e ); 268 | if(self.config.retry_errors && !retry){ 269 | var delay = self.config.error_retry_delay || 10000; 270 | self.conditional_console_log( "retrying once in " + delay + " milliseconds" ); 271 | setTimeout(function() { 272 | self.makeRequest(endpoint, method, data, callback, true); 273 | }, delay ); 274 | } else{ 275 | callback(e); 276 | } 277 | }); 278 | 279 | if (options.method === 'post' || options.method === 'put' || options.method === 'delete' || options.method === 'patch') { 280 | request.write(dataString); 281 | } 282 | 283 | request.end(); 284 | 285 | }; 286 | 287 | ShopifyAPI.prototype.get = function(endpoint, data, callback) { 288 | if (typeof data === 'function' && arguments.length < 3) { 289 | callback = data; 290 | data = null; 291 | } else { 292 | if(data){ 293 | endpoint += ((endpoint.indexOf('?') == -1) ? '?' : '&') + querystring.stringify(data); 294 | } 295 | data = null; 296 | } 297 | this.makeRequest(endpoint,'GET', data, callback); 298 | }; 299 | 300 | ShopifyAPI.prototype.post = function(endpoint, data, callback) { 301 | this.makeRequest(endpoint,'POST', data, callback); 302 | }; 303 | 304 | ShopifyAPI.prototype.put = function(endpoint, data, callback) { 305 | this.makeRequest(endpoint, 'PUT', data, callback); 306 | }; 307 | 308 | ShopifyAPI.prototype.delete = function(endpoint, data, callback) { 309 | if (arguments.length < 3) { 310 | if (typeof data === 'function') { 311 | callback = data; 312 | data = null; 313 | } else { 314 | callback = new Function; 315 | data = typeof data === 'undefined' ? null : data; 316 | } 317 | } 318 | this.makeRequest(endpoint, 'DELETE', data, callback); 319 | }; 320 | 321 | ShopifyAPI.prototype.patch = function(endpoint, data, callback) { 322 | this.makeRequest(endpoint, 'PATCH', data, callback); 323 | }; 324 | 325 | ShopifyAPI.prototype.has_header = function(response, header) { 326 | return response.headers.hasOwnProperty(header) ? true : false; 327 | }; 328 | 329 | ShopifyAPI.prototype.graphql = function(data, callback) { 330 | this.makeRequest('/admin/api/graphql.json','POST', data, callback); 331 | }; 332 | 333 | module.exports = ShopifyAPI; 334 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopify-node-api", 3 | "version": "1.11.0", 4 | "description": "OAuth2 Module for Shopify API", 5 | "main": "./lib/shopify", 6 | "types": "./shopify-node-api.d.ts", 7 | "scripts": { 8 | "test": "make test" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/BKnights/shopify-node-api.git" 13 | }, 14 | "keywords": [ 15 | "Shopify", 16 | "api", 17 | "node.js", 18 | "oauth2" 19 | ], 20 | "author": "Chris Gregory (http://sinelabs.com/)", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/BKnights/shopify-node-api/issues" 24 | }, 25 | "homepage": "https://github.com/BKnights/shopify-node-api", 26 | "dependencies": { 27 | "json-bigint": "^0.1.4" 28 | }, 29 | "devDependencies": { 30 | "mocha": "^1.20.1", 31 | "chai": "^1.9.1", 32 | "nock": "^7.5.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /shopify-node-api.d.ts: -------------------------------------------------------------------------------- 1 | declare module "shopify-node-api" { 2 | type UtilRequest = ( 3 | endpoint: string, 4 | data: Record, 5 | callback: ( 6 | error: null | Error, 7 | data: Record, 8 | headers?: Record, 9 | ) => void, 10 | ) => void 11 | type ResponseSignature = { 12 | hmac: string 13 | signature: string 14 | code: string 15 | nonce: string 16 | } 17 | type BaseConfig = { 18 | shop: string 19 | shopify_api_key: string 20 | backoff_level?: number 21 | verbose?: boolean 22 | verbose_body?: boolean 23 | verbose_status?: boolean 24 | verbose_headers?: boolean 25 | verbose_api_limit?: boolean 26 | } 27 | export type PublicAppConfig = BaseConfig & { 28 | shopify_shared_secret: string 29 | shopify_scope: string 30 | redirect_uri: string 31 | nonce: string 32 | } 33 | 34 | export type PrivateAppConfig = BaseConfig & { 35 | access_token: string 36 | } 37 | 38 | export default class ShopifyAPI { 39 | public config: PublicAppConfig | PrivateAppConfig 40 | 41 | /** 42 | * Configure this instance of the Shopify node api_ 43 | * @param {PublicAppConfig | PrivateAppConfig} config to init. 44 | */ 45 | constructor(config: PublicAppConfig | PrivateAppConfig) 46 | 47 | buildAuthURL(): string 48 | set_access_token(token: string): void 49 | conditional_console_log(message: unknown): void 50 | is_valid_signature(params: ResponseSignature, non_state?: boolean): boolean 51 | exchange_temporary_token( 52 | query_params: ResponseSignature, 53 | callback: ( 54 | error: null | Error, 55 | data: null | Record<"access_token", string>, 56 | ) => void, 57 | ): void 58 | hostname(): string 59 | port(): 443 60 | makeRequest( 61 | endpoint: string, 62 | method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", 63 | callback: ( 64 | error: null | Error, 65 | data: Record, 66 | headers?: Record, 67 | ) => void, 68 | retry?: boolean, 69 | ): void 70 | 71 | get: UtilRequest 72 | post: UtilRequest 73 | put: UtilRequest 74 | delete: UtilRequest 75 | patch: UtilRequest 76 | 77 | graphql( 78 | action: Record, 79 | callback: ( 80 | error: null | Error, 81 | data: null | Record, 82 | ) => void, 83 | ): void 84 | 85 | has_header(response: Response, header: string): boolean 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /test/shopify.js: -------------------------------------------------------------------------------- 1 | var should = require('chai').should(), 2 | expect = require('chai').expect, 3 | nock = require('nock'), 4 | shopifyAPI = require('../lib/shopify.js'), 5 | zlib = require('zlib'); 6 | 7 | describe('Constructor Function: #shopifyAPI', function(){ 8 | 9 | it('throws error if no config object is passed in', function(){ 10 | var msg = "ShopifyAPI module expects a config object\nPlease see documentation at: https://github.com/sinechris/shopify-node-api\n"; 11 | expect(function(){ 12 | var Shopify = new shopifyAPI(); 13 | }).to.throw(msg); 14 | }); 15 | 16 | it('returns instanceof shopifyAPI with "new" keyword', function(){ 17 | var Shopify = new shopifyAPI({}); 18 | expect(Shopify).to.be.a.instanceof(shopifyAPI); 19 | }); 20 | 21 | it('returns instanceof shopifyAPI without "new" keyword', function(){ 22 | var Shopify = shopifyAPI({}); 23 | expect(Shopify).to.be.a.instanceof(shopifyAPI); 24 | }); 25 | 26 | }); 27 | 28 | describe('#buildAuthURL', function(){ 29 | 30 | var Shopify = new shopifyAPI({ 31 | shop: 'MYSHOP', 32 | shopify_api_key: 'abc123', 33 | shopify_shared_secret: 'asdf1234', 34 | shopify_scope: 'write_products', 35 | redirect_uri: 'http://localhost:3000/finish_auth', 36 | nonce: 'abc123' 37 | }); 38 | 39 | 40 | it('builds correct string', function(){ 41 | var auth_url = Shopify.buildAuthURL(), 42 | correct_auth_url = 'https://MYSHOP.myshopify.com/admin/oauth/authorize?client_id=abc123&scope=write_products&redirect_uri=http://localhost:3000/finish_auth&state=abc123'; 43 | auth_url.should.equal(correct_auth_url); 44 | }); 45 | 46 | }); 47 | 48 | describe('#set_access_token', function(){ 49 | var Shopify = new shopifyAPI({}); 50 | 51 | it('should not have access_token property initially', function(){ 52 | Shopify.config.should.not.have.property('access_token'); 53 | }); 54 | 55 | it('should add correct access token to config object', function(){ 56 | Shopify.config.should.not.have.property('access_token'); 57 | var fake_token = '123456789'; 58 | Shopify.set_access_token(fake_token); 59 | Shopify 60 | .config 61 | .should 62 | .have 63 | .property('access_token') 64 | .with 65 | .length(fake_token.length); 66 | Shopify 67 | .config 68 | .access_token 69 | .should 70 | .equal(fake_token); 71 | }); 72 | 73 | }); 74 | 75 | describe('#is_valid_signature', function(){ 76 | it('should return correct signature', function(){ 77 | 78 | // Values used below were pre-calculated and not part 79 | // of an actual shop. 80 | 81 | var Shopify = shopifyAPI({ 82 | shopify_shared_secret: 'hush', 83 | nonce: 'abc123' 84 | }), 85 | params = { 86 | 'shop': 'some-shop.myshopify.com', 87 | 'code': 'a94a110d86d2452eb3e2af4cfb8a3828', 88 | 'timestamp': '1337178173', 89 | 'signature': '6e39a2ea9e497af6cb806720da1f1bf3', 90 | 'hmac': '62c96e47cdef32a33c6fa78d761e049b3578b8fc115188a9ffcd774937ab7c78', 91 | 'state': 'abc123' 92 | }; 93 | 94 | expect(Shopify.is_valid_signature(params)).to.equal(true); 95 | }); 96 | 97 | it('should ignore the state/nonce when non_state is true', function(){ 98 | 99 | // Values used below were pre-calculated and not part 100 | // of an actual shop. 101 | 102 | var Shopify = shopifyAPI({ 103 | shopify_shared_secret: 'hush', 104 | }), 105 | params = { 106 | 'shop': 'some-shop.myshopify.com', 107 | 'code': 'a94a110d86d2452eb3e2af4cfb8a3828', 108 | 'timestamp': '1337178173', 109 | 'signature': '6e39a2ea9e497af6cb806720da1f1bf3', 110 | 'hmac': '2cb1a277650a659f1b11e92a4a64275b128e037f2c3390e3c8fd2d8721dac9e2', 111 | }; 112 | 113 | expect(Shopify.is_valid_signature(params, true)).to.equal(true); 114 | }); 115 | }); 116 | 117 | describe('#exchange_temporary_token', function(){ 118 | it('should exchange a temporary token', function(done){ 119 | 120 | // Values used below were pre-calculated and not part 121 | // of an actual shop. 122 | 123 | var Shopify = shopifyAPI({ 124 | shop: 'myshop', 125 | shopify_api_key: 'abc123', 126 | shopify_shared_secret: 'hush', 127 | verbose: false, 128 | nonce: 'abc123' 129 | }), 130 | params = { 131 | 'shop': 'some-shop.myshopify.com', 132 | 'code': 'a94a110d86d2452eb3e2af4cfb8a3828', 133 | 'timestamp': '1337178173', 134 | 'signature': '6e39a2ea9e497af6cb806720da1f1bf3', 135 | 'hmac': '62c96e47cdef32a33c6fa78d761e049b3578b8fc115188a9ffcd774937ab7c78', 136 | 'state': 'abc123' 137 | }; 138 | 139 | var shopifyTokenFetch = nock('https://myshop.myshopify.com') 140 | .post('/admin/oauth/access_token') 141 | .reply(200, { 142 | "access_token": "abcd" 143 | }); 144 | 145 | Shopify.exchange_temporary_token(params, function(err, res) { 146 | if (err) { 147 | return done(err); 148 | } 149 | shopifyTokenFetch.done(); 150 | done(); 151 | }); 152 | }); 153 | 154 | it('should return an error object with a legible message', function(done) { 155 | var Shopify = shopifyAPI({ 156 | shop: 'myshop', 157 | shopify_api_key: 'abc123', 158 | shopify_shared_secret: 'hush', 159 | verbose: false, 160 | nonce: 'abc123' 161 | }), 162 | params = { 163 | 'shop': 'some-shop.myshopify.com', 164 | 'code': 'a94a110d86d2452eb3e2af4cfb8a3828', 165 | 'timestamp': '1337178173', 166 | 'signature': '6e39a2ea9e497af6cb806720da1f1bf3', 167 | 'hmac': '62c96e47cdef32a33c6fa78d761e049b3578b8fc115188a9ffcd774937ab7c78', 168 | 'state': 'abc123' 169 | }; 170 | 171 | // Shopify will return an invalid request in some cases, e.g. if a code 172 | // is not valid for exchanging to a permanent token. 173 | var shopifyTokenFetch = nock('https://myshop.myshopify.com') 174 | .post('/admin/oauth/access_token') 175 | .reply(400, { 176 | error: "invalid_request" 177 | }); 178 | 179 | Shopify.exchange_temporary_token(params, function(err, res) { 180 | shopifyTokenFetch.done(); 181 | expect(err).to.be.instanceof(Error); 182 | expect(err.message).to.equal("invalid_request"); 183 | done(); 184 | }); 185 | }); 186 | }); 187 | 188 | describe('#get', function(){ 189 | it('should return correct response', function(done){ 190 | 191 | var shopify_get = nock('https://myshop.myshopify.com') 192 | .get('/admin/products/count.json') 193 | .reply(200, { 194 | "count": 2 195 | }); 196 | 197 | var Shopify = shopifyAPI({ 198 | shop: 'myshop', 199 | shopify_api_key: 'abc123', 200 | shopify_shared_secret: 'asdf1234', 201 | shopify_scope: 'write_products', 202 | redirect_uri: 'http://localhost:3000/finish_auth', 203 | verbose: false 204 | }); 205 | 206 | Shopify.get('/admin/products/count.json', function(err, data, headers){ 207 | expect(data).to.deep.equal({"count": 2}); 208 | done(); 209 | }); 210 | 211 | }); 212 | 213 | it('should parse a gzip response', function(done){ 214 | var buf = new Buffer(JSON.stringify({ count: 2 })); 215 | zlib.gzip(buf, function(err, res) { 216 | if (err) { 217 | return done(err); 218 | } 219 | var shopify_get = nock('https://myshop.myshopify.com') 220 | .get('/admin/products/count.json') 221 | .reply(200, res, { 222 | 'X-Transfer-Length': String(res.length), 223 | 'Content-Length': undefined, 224 | 'Content-Encoding': 'gzip', 225 | 'Content-Type': 'application/json' 226 | }); 227 | 228 | var Shopify = shopifyAPI({ 229 | shop: 'myshop', 230 | shopify_api_key: 'abc123', 231 | shopify_shared_secret: 'asdf1234', 232 | shopify_scope: 'write_products', 233 | redirect_uri: 'http://localhost:3000/finish_auth', 234 | verbose: false 235 | }); 236 | 237 | Shopify.get('/admin/products/count.json', function(err, data, headers){ 238 | expect(data).to.deep.equal({"count": 2}); 239 | done(); 240 | }); 241 | }); 242 | }); 243 | 244 | it('should parse a number too large for javascript into a string', function(done) { 245 | var shopify_get = nock('https://myshop.myshopify.com') 246 | .get('/admin/orders.json') 247 | .reply(200, '{"id": 9223372036854775807}'); 248 | 249 | var Shopify = shopifyAPI({ 250 | shop: 'myshop', 251 | shopify_api_key: 'abc123', 252 | shopify_shared_secret: 'asdf1234', 253 | shopify_scope: 'write_products', 254 | redirect_uri: 'http://localhost:3000/finish_auth', 255 | verbose: false 256 | }); 257 | 258 | 259 | Shopify.get('/admin/orders.json', function(err, data, headers){ 260 | expect(data.id.toString()).to.equal('9223372036854775807'); 261 | done(); 262 | }); 263 | }); 264 | 265 | it('should accept an agent for https', function(done) { 266 | var Agent = require('https').Agent; 267 | var agent = new Agent({ 268 | keepAlive: true, 269 | keepAliveMsecs: 1000 * 10, 270 | maxSockets: 10, 271 | maxFreeSockets: 10 272 | }); 273 | var shopify_get = nock('https://myshop.myshopify.com') 274 | .get('/admin/orders.json') 275 | .reply(200, '{"id": 1}'); 276 | 277 | var Shopify = shopifyAPI({ 278 | shop: 'myshop', 279 | shopify_api_key: 'abc123', 280 | shopify_shared_secret: 'asdf1234', 281 | shopify_scope: 'write_products', 282 | redirect_uri: 'http://localhost:3000/finish_auth', 283 | verbose: false, 284 | agent: agent 285 | }); 286 | 287 | Shopify.get('/admin/orders.json', function(err, data, headers, opts) { 288 | expect(err).to.not.exist(); 289 | expect(opts.agent).to.equal(agent); 290 | return done(); 291 | }); 292 | }); 293 | 294 | it('should parse data argument into a querystring and append it to endpoint', function(done) { 295 | var shopify_get = nock('https://myshop.myshopify.com') 296 | .get('/admin/products.json') 297 | .query(true) 298 | .reply(200, function(uri, reqBody) { 299 | return {uri: uri}; 300 | }); 301 | 302 | var Shopify = shopifyAPI({ 303 | shop: 'myshop', 304 | shopify_api_key: 'abc123', 305 | shopify_shared_secret: 'asdf1234', 306 | shopify_scope: 'write_products', 307 | redirect_uri: 'http://localhost:3000/finish_auth', 308 | verbose: false 309 | }); 310 | 311 | 312 | Shopify.get('/admin/products.json', {page: 2, limit: 15}, function(err, data, headers){ 313 | expect(data.uri).to.equal('/admin/products.json?page=2&limit=15'); 314 | done(); 315 | }); 316 | }); 317 | 318 | it('should use error_description when available', function(done) { 319 | var shopify_get = nock('https://myshop.myshopify.com') 320 | .get('/') 321 | .reply(400, function(uri, reqBody) { 322 | return {'error':'abc','error_description':'xyz'}; 323 | }); 324 | 325 | var Shopify = shopifyAPI({ 326 | shop: 'myshop', 327 | shopify_api_key: 'abc123', 328 | shopify_shared_secret: 'asdf1234', 329 | shopify_scope: 'write_products', 330 | redirect_uri: 'http://localhost:3000/finish_auth', 331 | verbose: false 332 | }); 333 | 334 | Shopify.get('/', function(err, data, headers){ 335 | expect(err).to.deep.equal({ error: 'xyz', code: 400 }); 336 | done(); 337 | }); 338 | }); 339 | 340 | it('should use error when error_description is not available', function(done) { 341 | var shopify_get = nock('https://myshop.myshopify.com') 342 | .get('/') 343 | .reply(400, function(uri, reqBody) { 344 | return {'error':'abc'}; 345 | }); 346 | 347 | var Shopify = shopifyAPI({ 348 | shop: 'myshop', 349 | shopify_api_key: 'abc123', 350 | shopify_shared_secret: 'asdf1234', 351 | shopify_scope: 'write_products', 352 | redirect_uri: 'http://localhost:3000/finish_auth', 353 | verbose: false 354 | }); 355 | 356 | Shopify.get('/', function(err, data, headers){ 357 | expect(err).to.deep.equal({ error: 'abc', code: 400 }); 358 | done(); 359 | }); 360 | }); 361 | 362 | it('should use errors when error_description and error is not available', function(done) { 363 | var shopify_get = nock('https://myshop.myshopify.com') 364 | .get('/') 365 | .reply(400, function(uri, reqBody) { 366 | return {'errors':'abc'}; 367 | }); 368 | 369 | var Shopify = shopifyAPI({ 370 | shop: 'myshop', 371 | shopify_api_key: 'abc123', 372 | shopify_shared_secret: 'asdf1234', 373 | shopify_scope: 'write_products', 374 | redirect_uri: 'http://localhost:3000/finish_auth', 375 | verbose: false 376 | }); 377 | 378 | Shopify.get('/', function(err, data, headers){ 379 | expect(err).to.deep.equal({ error: 'abc', code: 400 }); 380 | done(); 381 | }); 382 | }); 383 | }); 384 | 385 | describe('#post', function(){ 386 | it('should return correct response', function(done){ 387 | 388 | var post_data = { 389 | "product": { 390 | "title": "Burton Custom Freestlye 151", 391 | "body_html": "Good snowboard!", 392 | "vendor": "Burton", 393 | "product_type": "Snowboard", 394 | "variants": [ 395 | { 396 | "option1": "First", 397 | "price": "10.00", 398 | "sku": 123 399 | }, 400 | { 401 | "option1": "Second", 402 | "price": "20.00", 403 | "sku": "123" 404 | } 405 | ] 406 | } 407 | }, 408 | response = { 409 | "product": { 410 | "body_html": "Good snowboard!", 411 | "created_at": "2014-05-23T14:18:12-04:00", 412 | "handle": "burton-custom-freestlye-151", 413 | "id": 1071559674, 414 | "product_type": "Snowboard", 415 | "published_at": "2014-05-23T14:18:12-04:00", 416 | "published_scope": "global", 417 | "template_suffix": null, 418 | "title": "Burton Custom Freestlye 151", 419 | "updated_at": "2014-05-23T14:18:12-04:00", 420 | "vendor": "Burton", 421 | "tags": "", 422 | "variants": [ 423 | { 424 | "barcode": null, 425 | "compare_at_price": null, 426 | "created_at": "2014-05-23T14:18:12-04:00", 427 | "fulfillment_service": "manual", 428 | "grams": 0, 429 | "id": 1044399349, 430 | "inventory_management": null, 431 | "inventory_policy": "deny", 432 | "option1": "First", 433 | "option2": null, 434 | "option3": null, 435 | "position": 1, 436 | "price": "10.00", 437 | "product_id": 1071559674, 438 | "requires_shipping": true, 439 | "sku": "123", 440 | "taxable": true, 441 | "title": "First", 442 | "updated_at": "2014-05-23T14:18:12-04:00", 443 | "inventory_quantity": 1, 444 | "old_inventory_quantity": 1 445 | }, 446 | { 447 | "barcode": null, 448 | "compare_at_price": null, 449 | "created_at": "2014-05-23T14:18:12-04:00", 450 | "fulfillment_service": "manual", 451 | "grams": 0, 452 | "id": 1044399350, 453 | "inventory_management": null, 454 | "inventory_policy": "deny", 455 | "option1": "Second", 456 | "option2": null, 457 | "option3": null, 458 | "position": 2, 459 | "price": "20.00", 460 | "product_id": 1071559674, 461 | "requires_shipping": true, 462 | "sku": "123", 463 | "taxable": true, 464 | "title": "Second", 465 | "updated_at": "2014-05-23T14:18:12-04:00", 466 | "inventory_quantity": 1, 467 | "old_inventory_quantity": 1 468 | } 469 | ], 470 | "options": [ 471 | { 472 | "id": 1020890454, 473 | "name": "Title", 474 | "position": 1, 475 | "product_id": 1071559674 476 | } 477 | ], 478 | "images": [ 479 | 480 | ] 481 | } 482 | }; 483 | 484 | var shopify_get = nock('https://myshop.myshopify.com') 485 | .post('/admin/products.json') 486 | .reply(200, response); 487 | 488 | var Shopify = shopifyAPI({ 489 | shop: 'myshop', 490 | shopify_api_key: 'abc123', 491 | shopify_shared_secret: 'asdf1234', 492 | shopify_scope: 'write_products', 493 | redirect_uri: 'http://localhost:3000/finish_auth', 494 | verbose: false 495 | }); 496 | 497 | Shopify.post('/admin/products.json', post_data, function(err, data, headers){ 498 | expect(data).to.deep.equal(response); 499 | done(); 500 | }); 501 | 502 | }); 503 | }); 504 | 505 | describe('#put', function(){ 506 | it('should return correct response', function(done){ 507 | 508 | var put_data = { 509 | "product": { 510 | "id": 632910392, 511 | "title": "New product title" 512 | } 513 | }, 514 | response = { 515 | "product": { 516 | "body_html": "

It's the small iPod with one very big idea: Video. Now the world's most popular music player, available in 4GB and 8GB models, lets you enjoy TV shows, movies, video podcasts, and more. The larger, brighter display means amazing picture quality. In six eye-catching colors, iPod nano is stunning all around. And with models starting at just $149, little speaks volumes.

", 517 | "created_at": "2014-05-23T14:17:55-04:00", 518 | "handle": "ipod-nano", 519 | "id": 632910392, 520 | "product_type": "Cult Products", 521 | "published_at": "2007-12-31T19:00:00-05:00", 522 | "published_scope": "global", 523 | "template_suffix": null, 524 | "title": "New product title", 525 | "updated_at": "2014-05-23T14:18:15-04:00", 526 | "vendor": "Apple", 527 | "tags": "Emotive, Flash Memory, MP3, Music", 528 | "variants": [ 529 | { 530 | "barcode": "1234_pink", 531 | "compare_at_price": null, 532 | "created_at": "2014-05-23T14:17:55-04:00", 533 | "fulfillment_service": "manual", 534 | "grams": 200, 535 | "id": 808950810, 536 | "inventory_management": "shopify", 537 | "inventory_policy": "continue", 538 | "option1": "Pink", 539 | "option2": null, 540 | "option3": null, 541 | "position": 1, 542 | "price": "199.00", 543 | "product_id": 632910392, 544 | "requires_shipping": true, 545 | "sku": "IPOD2008PINK", 546 | "taxable": true, 547 | "title": "Pink", 548 | "updated_at": "2014-05-23T14:17:55-04:00", 549 | "inventory_quantity": 10, 550 | "old_inventory_quantity": 10 551 | }, 552 | { 553 | "barcode": "1234_red", 554 | "compare_at_price": null, 555 | "created_at": "2014-05-23T14:17:55-04:00", 556 | "fulfillment_service": "manual", 557 | "grams": 200, 558 | "id": 49148385, 559 | "inventory_management": "shopify", 560 | "inventory_policy": "continue", 561 | "option1": "Red", 562 | "option2": null, 563 | "option3": null, 564 | "position": 2, 565 | "price": "199.00", 566 | "product_id": 632910392, 567 | "requires_shipping": true, 568 | "sku": "IPOD2008RED", 569 | "taxable": true, 570 | "title": "Red", 571 | "updated_at": "2014-05-23T14:17:55-04:00", 572 | "inventory_quantity": 20, 573 | "old_inventory_quantity": 20 574 | }, 575 | { 576 | "barcode": "1234_green", 577 | "compare_at_price": null, 578 | "created_at": "2014-05-23T14:17:55-04:00", 579 | "fulfillment_service": "manual", 580 | "grams": 200, 581 | "id": 39072856, 582 | "inventory_management": "shopify", 583 | "inventory_policy": "continue", 584 | "option1": "Green", 585 | "option2": null, 586 | "option3": null, 587 | "position": 3, 588 | "price": "199.00", 589 | "product_id": 632910392, 590 | "requires_shipping": true, 591 | "sku": "IPOD2008GREEN", 592 | "taxable": true, 593 | "title": "Green", 594 | "updated_at": "2014-05-23T14:17:55-04:00", 595 | "inventory_quantity": 30, 596 | "old_inventory_quantity": 30 597 | }, 598 | { 599 | "barcode": "1234_black", 600 | "compare_at_price": null, 601 | "created_at": "2014-05-23T14:17:55-04:00", 602 | "fulfillment_service": "manual", 603 | "grams": 200, 604 | "id": 457924702, 605 | "inventory_management": "shopify", 606 | "inventory_policy": "continue", 607 | "option1": "Black", 608 | "option2": null, 609 | "option3": null, 610 | "position": 4, 611 | "price": "199.00", 612 | "product_id": 632910392, 613 | "requires_shipping": true, 614 | "sku": "IPOD2008BLACK", 615 | "taxable": true, 616 | "title": "Black", 617 | "updated_at": "2014-05-23T14:17:55-04:00", 618 | "inventory_quantity": 40, 619 | "old_inventory_quantity": 40 620 | } 621 | ], 622 | "options": [ 623 | { 624 | "id": 594680422, 625 | "name": "Title", 626 | "position": 1, 627 | "product_id": 632910392 628 | } 629 | ], 630 | "images": [ 631 | { 632 | "created_at": "2014-05-23T14:17:55-04:00", 633 | "id": 850703190, 634 | "position": 1, 635 | "product_id": 632910392, 636 | "updated_at": "2014-05-23T14:17:55-04:00", 637 | "src": "http://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1400869075" 638 | }, 639 | { 640 | "created_at": "2014-05-23T14:17:55-04:00", 641 | "id": 562641783, 642 | "position": 2, 643 | "product_id": 632910392, 644 | "updated_at": "2014-05-23T14:17:55-04:00", 645 | "src": "http://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano-2.png?v=1400869075" 646 | } 647 | ], 648 | "image": { 649 | "created_at": "2014-05-23T14:17:55-04:00", 650 | "id": 850703190, 651 | "position": 1, 652 | "product_id": 632910392, 653 | "updated_at": "2014-05-23T14:17:55-04:00", 654 | "src": "http://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1400869075" 655 | } 656 | } 657 | }; 658 | 659 | var shopify_get = nock('https://myshop.myshopify.com') 660 | .put('/admin/products/12345.json') 661 | .reply(200, response); 662 | 663 | var Shopify = shopifyAPI({ 664 | shop: 'myshop', 665 | shopify_api_key: 'abc123', 666 | shopify_shared_secret: 'asdf1234', 667 | shopify_scope: 'write_products', 668 | redirect_uri: 'http://localhost:3000/finish_auth', 669 | verbose: false 670 | }); 671 | 672 | Shopify.put('/admin/products/12345.json', put_data, function(err, data, headers){ 673 | expect(data).to.deep.equal(response); 674 | done(); 675 | }); 676 | 677 | }); 678 | }); 679 | 680 | describe('#delete', function(){ 681 | it('should return correct response', function(done){ 682 | 683 | var shopify_get = nock('https://myshop.myshopify.com') 684 | .delete('/admin/products/12345.json') 685 | .reply(200, {}); 686 | 687 | var Shopify = shopifyAPI({ 688 | shop: 'myshop', 689 | shopify_api_key: 'abc123', 690 | shopify_shared_secret: 'asdf1234', 691 | shopify_scope: 'write_products', 692 | redirect_uri: 'http://localhost:3000/finish_auth', 693 | verbose: false 694 | }); 695 | 696 | Shopify.delete('/admin/products/12345.json', function(err, data, headers){ 697 | expect(data).to.deep.equal({}); 698 | done(); 699 | }); 700 | 701 | }); 702 | }); 703 | 704 | describe('#graphql', function(){ 705 | it('should return correct response', function(done){ 706 | 707 | var graphql_data = { 708 | query: '{shop{id}}', 709 | variables: {} 710 | } 711 | response = { 712 | data: { 713 | shop: { 714 | id: 'gid:\/\/shopify\/Shop\/1234567' 715 | } 716 | }, 717 | extensions: { 718 | cost: { 719 | requestedQueryCost: 1, 720 | actualQueryCost: 1, 721 | throttleStatus: { 722 | maximumAvailable: 1000.0, 723 | currentlyAvailable: 999, 724 | restoreRate: 50.0 725 | } 726 | } 727 | } 728 | }; 729 | 730 | var shopify_get = nock('https://myshop.myshopify.com') 731 | .post('/admin/api/graphql.json') 732 | .reply(200, response); 733 | 734 | var Shopify = shopifyAPI({ 735 | shop: 'myshop', 736 | shopify_api_key: 'abc123', 737 | shopify_shared_secret: 'asdf1234', 738 | shopify_scope: 'write_products', 739 | redirect_uri: 'http://localhost:3000/finish_auth', 740 | verbose: false 741 | }); 742 | 743 | Shopify.graphql(graphql_data, function(err, data, headers){ 744 | expect(data).to.deep.equal(response); 745 | done(); 746 | }); 747 | }); 748 | }); 749 | --------------------------------------------------------------------------------