├── .editorconfig ├── .gitignore ├── .npmrc ├── .nvmrc ├── LICENSE ├── Procfile ├── README.md ├── api ├── cache.js ├── index.js ├── router.js ├── util.js └── v1 │ ├── account.js │ ├── cart.js │ ├── categories.js │ ├── contacts.js │ ├── pages.js │ ├── products.js │ ├── session.js │ └── slug.js ├── env.js ├── nodemon.json ├── package.json ├── server.js └── test ├── api └── v1 │ ├── account.spec.js │ ├── cart.spec.js │ ├── categories.spec.js │ ├── contacts.spec.js │ ├── pages.spec.js │ ├── products.spec.js │ ├── session.spec.js │ └── slug.spec.js └── index.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | max_line_length = 0 15 | trim_trailing_whitespace = false 16 | 17 | [COMMIT_EDITMSG] 18 | max_line_length = 0 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | bower_components 29 | 30 | # Optional npm cache directory 31 | .npm 32 | 33 | # Optional REPL history 34 | .node_repl_history 35 | 36 | # New Relic 37 | newrelic_agent.log 38 | 39 | # Mac 40 | .DS_Store 41 | 42 | # Custom 43 | .env 44 | assets 45 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save=true 2 | save-exact=true 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v6.5.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Schema Technologies, LLC 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Schema NodeJS API 2 | 3 | A standalone API for JS and other clients to use with Schema. Extend basic endpoints with your own custom functionality, for example, validating user input special or scoping product queries for client usage. 4 | 5 | ``` 6 | Schema.io <==> NodeJS API (This Package) <==> Your App (React/Angular/Ember/Rails/Etc) 7 | ``` 8 | 9 | ## Getting Started 10 | 11 | - Clone this repository 12 | - Create `.env` file in the repository and set `development` values (see example `.env` file) 13 | - Run `nvm install` (make sure you have [nvm installed](https://github.com/creationix/nvm)) 14 | - Run `npm install` 15 | - Run `npm run watch` 16 | 17 | ### Example .env file 18 | 19 | ```bash 20 | NODE_ENV=development 21 | PORT=3001 22 | FORCE_SSL=true # redirect all requests to https 23 | SCHEMA_CLIENT_ID=my-client-id 24 | SCHEMA_CLIENT_KEY=my-client-key 25 | ``` 26 | 27 | # API Usage 28 | 29 | ### Sessions 30 | 31 | Sessions are used to track persistent data associated with the end user, such as Account and Cart records. The client should pass an HTTP header named `X-Session` in every request to identify the current user. 32 | 33 | ``` 34 | X-Session: a4916a9b-717b-4d68-bb78-e5fc8e51591f 35 | ``` 36 | 37 | Your session ID can be of any format and should be created and stored by the client, typically stored as a cookie in a browser. 38 | 39 | It's not strictly required to pass the session header, but certain endpoints will throw an error without one. 40 | 41 | ### /v1/account 42 | 43 | #### Get current account (session required) 44 | 45 | ```json 46 | GET /v1/account 47 | ``` 48 | 49 | Sensitive fields may be removed from the response. See `api/v1/account.js` for details. 50 | 51 | #### Update current account 52 | 53 | ```json 54 | PUT /v1/account 55 | { 56 | "first_name": "Example", 57 | "last_name": "Customer", 58 | "email": "customer@example.com" 59 | } 60 | ``` 61 | 62 | Only specific fields may be updated. See `api/v1/account.js` for details. 63 | 64 | #### Create account for the current user 65 | 66 | ```json 67 | POST /v1/account 68 | { 69 | "first_name": "Example", 70 | "last_name": "Customer", 71 | "email": "customer@example.com" 72 | } 73 | ``` 74 | 75 | Only specific fields may be created. See `api/v1/account.js` for details. 76 | 77 | #### Login to account 78 | 79 | ```json 80 | POST /v1/account/login 81 | { 82 | "email": "customer@example.com", 83 | "password": "example password" 84 | } 85 | ``` 86 | 87 | If email and password are correct, an account record will be returned. Otherwise `null`. 88 | 89 | #### Logout of current account 90 | 91 | ```json 92 | POST /v1/account/logout 93 | ``` 94 | 95 | This will remove the previously logged in `account_id` from the session. 96 | 97 | #### Send password recovery email 98 | 99 | ```json 100 | POST /v1/account/recover 101 | { 102 | "email": "customer@example.com", 103 | "reset_url": "https://mystore.com/account/recover/{key}" 104 | } 105 | ``` 106 | 107 | This will send an email to the account if one found, that contains the `reset_url` parameter and a dynamically generated `key` appended to it. Your recovery page must recognize this `key` parameter and use it in the following step. 108 | 109 | #### Reset password from recovery email 110 | 111 | ```json 112 | POST /v1/account/recover 113 | { 114 | "reset_key": "iud287ebuf9uwf92fdi2uhef872h", 115 | "password": "new password" 116 | } 117 | ``` 118 | 119 | This will reset an account's password if the `reset_key` is found. If successful, it will return an account record and automatically login using the current session. If the `reset_key` has expired or is not found, it will return an error. 120 | 121 | #### List account orders 122 | 123 | ```json 124 | GET /v1/account/orders 125 | { 126 | "limit": 10, 127 | "page": 1 128 | } 129 | ``` 130 | 131 | Get a list of orders placed by the current logged in account. 132 | 133 | #### Get a single account order 134 | 135 | ```json 136 | GET /v1/account/orders/:id 137 | ``` 138 | 139 | Get a single order placed by the current logged in account. 140 | 141 | #### List account addresses (Address Book) 142 | 143 | ```json 144 | GET /v1/account/addresses 145 | ``` 146 | 147 | Get a list of addresses stored by the current logged in account. 148 | 149 | #### List account credit cards 150 | 151 | ```json 152 | GET /v1/account/cards 153 | ``` 154 | 155 | Get a list of credit cards stored by the current logged in account. 156 | 157 | #### List account reviews 158 | 159 | ```json 160 | GET /v1/account/reviews 161 | ``` 162 | 163 | Get a list of product reviews created by the current logged in account. 164 | 165 | #### List account credits 166 | 167 | ```json 168 | GET /v1/account/credits 169 | ``` 170 | 171 | Get a list of credits applied to the current logged in account. 172 | 173 | ## /v1/cart 174 | 175 | #### Get current cart details (session required) 176 | 177 | ```json 178 | GET /v1/cart 179 | ``` 180 | 181 | Sensitive fields may be removed from the response. See `api/v1/cart.js` for details. 182 | 183 | #### Update current cart details 184 | 185 | ```json 186 | PUT /v1/cart 187 | { 188 | "shipping": { 189 | "name": "Example Customer", 190 | "address1": "123 Example St", 191 | "city": "Example City", 192 | "state": "EX", 193 | "zip": "90210", 194 | "country": "US", 195 | "service": "fedex_ground" 196 | }, 197 | "billing": { 198 | "name": "Example Customer", 199 | "address1": "123 Example St", 200 | "city": "Example City", 201 | "state": "EX", 202 | "zip": "90210", 203 | "country": "US", 204 | "card": { 205 | "token": "tok_..." 206 | } 207 | }, 208 | "items": [ 209 | { 210 | "product_id": "...", 211 | "quantity": 3, 212 | "options": [ 213 | { 214 | "id": "optional", 215 | "value": "example" 216 | } 217 | ] 218 | } 219 | ] 220 | } 221 | ``` 222 | 223 | Only specific fields may be updated. See `api/v1/cart.js` for details. 224 | 225 | This endpoint supports incremental updates for multi-page checkout flows. For example, you might pass `shipping` details in one request, and `billing` details in another request. Also, you'll probably want to use `/v1/cart/add-item` and `/v1/cart/remove-item` for incremental item updates. 226 | 227 | If the cart does not exist for the current session, it will be automatically created. 228 | 229 | #### Create cart for the current user 230 | 231 | ```json 232 | POST /v1/cart 233 | ``` 234 | 235 | This will create a cart for the current session, if one does not already exist. Note that it's not usually necessary to make this request, since other requests like `/v1/cart/add-item` will do the same automatically on demand. 236 | 237 | #### Add item to cart 238 | 239 | ```json 240 | POST /v1/cart/add-item 241 | { 242 | "product_id": "...", 243 | "quantity": 3, 244 | "options": [ 245 | { 246 | "id": "optional", 247 | "value": "example" 248 | } 249 | ] 250 | } 251 | ``` 252 | 253 | This will add an item to the cart. If the cart does not exist for the current session, it will be automatically created. 254 | 255 | If the same product and options already exist in the cart, then its quantity will combined into a single item. 256 | 257 | #### Remove item from cart 258 | 259 | ```json 260 | POST /v1/cart/remove-item 261 | { 262 | "item_id": "..." 263 | } 264 | ``` 265 | 266 | This will remove an item from the cart matching `item_id`. You can get the item ID value from any of the previous calls to the cart endpoint. 267 | 268 | #### Apply a coupon 269 | 270 | ```json 271 | POST /v1/cart/apply-coupon 272 | { 273 | "code": "SHIPFREE" 274 | } 275 | ``` 276 | 277 | This will apply a valid coupon code to the cart and affect all relevant prices. If the coupon code is not found or is not valid, the server will respond with status `400` and an error message. 278 | 279 | To remove an applied coupon, make the same request with `"code": null`. 280 | 281 | #### Get shipping prices 282 | 283 | ```json 284 | GET /v1/cart/shipment-rating 285 | ``` 286 | 287 | This will return an object with shipping services and prices relevant to the current cart state. For example, if your setup is configured with FedEx Ground enabled and real-time pricing, then you will get an object containing that shipping method and others available to the current shipping address in the cart. 288 | 289 | The typical flow is to update the cart with shipping information first, then make this request to get available shipping options and prices. 290 | 291 | ### Convert cart to order 292 | 293 | ```json 294 | POST /v1/cart/checkout 295 | ``` 296 | 297 | This will attempt to convert a cart to an order including all the cart's details such as items, shipping, and billing. You may optionally pass any of these details along with this request in order to consolidate API calls. 298 | 299 | If successful, an order record will be returned. Otherwise an error. 300 | 301 | ## /v1/categories 302 | 303 | #### Get category by slug or ID 304 | 305 | ```json 306 | GET /v1/categories/:slug 307 | ``` 308 | 309 | Sensitive fields may be removed from the response. See `api/v1/categories.js` for details. 310 | 311 | #### Get sub-categories by slug or ID 312 | 313 | ```json 314 | GET /v1/categories/:slug/children 315 | ``` 316 | 317 | #### Get products in a category 318 | 319 | ```json 320 | GET /v1/categories/:slug/products 321 | ``` 322 | 323 | This will return all products in a category, including all nested sub-categories. For example, if `:id` is "jellybeans" which contains sub-categories "red" and "blue", then the result will contain all red and blue jellybean products. 324 | 325 | Note: If you want to get products in a single category only and ignore sub-categories, then see `/v1/products` section. 326 | 327 | ## /v1/products 328 | 329 | #### Get a product 330 | 331 | ```json 332 | GET /v1/products/:id 333 | ``` 334 | 335 | Sensitive fields may be removed from the response. See `api/v1/products.js` for details. 336 | 337 | #### Get products in a category 338 | 339 | ```json 340 | GET /v1/products?category=:slug 341 | ``` 342 | 343 | This will return all products contain in a single category, ignoring sub-categories. 344 | 345 | Note: If you want to get products including all sub-categories, then see `/v1/categories` section. 346 | 347 | ## /v1/contacts 348 | 349 | #### Subscribe contact to email list(s) 350 | 351 | ```json 352 | POST /v1/contacts/subscribe 353 | { 354 | "first_name": "Example", 355 | "last_name": "Customer", 356 | "email": "customer@example.com", 357 | "email_optin_lists": ["optional", "lists"] 358 | } 359 | ``` 360 | 361 | All fields are optional except `email`. You may use `email_optin_lists` for tracking your own custom email list segments. 362 | 363 | #### Unsubscribe contact from email list(s) 364 | 365 | ```json 366 | POST /v1/contacts/unsubscribe 367 | { 368 | "email": "customer@example.com", 369 | "email_optin_lists": ["optional"] 370 | } 371 | ``` 372 | 373 | This will flip the `email_optin` field to false on the contact record. If `email_optin_lists` is passed, then it will remove those lists from the contact record. 374 | 375 | 376 | ## /v1/pages 377 | 378 | #### Get a page 379 | 380 | ```json 381 | GET /v1/pages/:id 382 | ``` 383 | 384 | Pages are used to store content such as About Us, Privacy Policy, etcetera. 385 | 386 | #### Get page articles 387 | 388 | ```json 389 | GET /v1/pages/:id/articles 390 | ``` 391 | 392 | Articles are useful for different things depending on the page itself. For example, you might think of articles on a Gallery page as images, or articles in a knowledge base. Use your imagination. 393 | 394 | 395 | #### Get a page article 396 | 397 | ```json 398 | GET /v1/pages/:id/articles/:article_id 399 | ``` 400 | 401 | ## /v1/sessions 402 | 403 | #### Get current session details 404 | 405 | ```json 406 | GET /v1/session 407 | ``` 408 | 409 | This will return current session data, including `account_id` and `cart_id`, along with any other arbitrary fields stored by your client. 410 | 411 | #### Update current session details 412 | 413 | ```json 414 | PUT /v1/session 415 | { 416 | "arbitrary_field": "example" 417 | } 418 | ``` 419 | 420 | Update the current session with any fields that might be useful to your client. Specific fields such as `account_id` and `cart_id` are restricted and will result in an error if passed. 421 | 422 | ## Testing 423 | 424 | ```bash 425 | npm test 426 | ``` 427 | 428 | As a best practice, you should write tests for all new or modified endpoints. 429 | 430 | #### Schema client testing 431 | 432 | The test setup includes a Schema client that should be used to stub itself with expected requests and results. This allows you to easily test your own API code, without calling out to the Schema.io API itself. It makes test faster and more reliable. 433 | 434 | Here's an example using this test client: 435 | 436 | ```javascript 437 | const schema = Test.schemaClient(); 438 | const api = Test.apiClient(schema); 439 | 440 | describe('/v1/account', () => { 441 | describe('PUT /v1/account', () => { 442 | it('throws an error when logged out', () => { 443 | return api.put('/v1/account').then(result => { 444 | assert.ok(result && result.error); 445 | }); 446 | }); 447 | 448 | describe('when logged in', () => { 449 | beforeEach(() => { 450 | schema.reset(); 451 | schema.expectLoggedIn(); 452 | }); 453 | 454 | it('sets account fields which are allowed', () => { 455 | schema.expects([ 456 | { 457 | method: 'put', 458 | url: '/accounts/{id}', 459 | result: { 460 | first_name: 'foo', 461 | last_name: 'bar', 462 | } 463 | } 464 | ]); 465 | return api.put('/v1/account', { 466 | first_name: 'foo', 467 | last_name: 'bar', 468 | }).then(result => { 469 | assert.ok(result && result.first_name); 470 | }); 471 | }); 472 | 473 | it('throws an error when updating restricted fields', () => { 474 | return api.put('/v1/account', { 475 | bad_field: 'foo', 476 | }).then(result => { 477 | assert.ok(result && result.error); 478 | assert.equal(result.error, 'Error: Query may not specify `bad_field` on this resource'); 479 | }); 480 | }); 481 | }); 482 | }); 483 | }); 484 | ``` 485 | 486 | ## Support 487 | 488 | Need help with this package? Visit us in Slack at https://slack.schema.io 489 | 490 | ## Contributing 491 | 492 | Pull requests are welcome. 493 | 494 | ## License 495 | 496 | MIT 497 | -------------------------------------------------------------------------------- /api/cache.js: -------------------------------------------------------------------------------- 1 | const apicache = require('apicache'); 2 | 3 | module.exports = (env) => { 4 | if (!env.CACHE_SECONDS) { 5 | return (req, res, next) => next(); 6 | } 7 | return apicache.options({ 8 | debug: env.NODE_ENV === 'development', 9 | appendKey: [ 'sessionID' ], 10 | }).middleware(env.CACHE_SECONDS + ' seconds'); 11 | }; 12 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | const Schema = require('schema-client'); 2 | 3 | module.exports = (env, schema) => { 4 | const api = require('./router')(); 5 | 6 | // Connect Schema 7 | schema = schema || new Schema.Client( 8 | env.SCHEMA_CLIENT_ID, 9 | env.SCHEMA_CLIENT_KEY 10 | ); 11 | 12 | // v1 router 13 | const router1 = initRouter(env, schema, [ 14 | './v1/account', 15 | './v1/cart', 16 | './v1/categories', 17 | './v1/contacts', 18 | './v1/pages', 19 | './v1/products', 20 | './v1/session', 21 | './v1/slug' 22 | ]); 23 | 24 | // Mount 25 | api.use('/v1', router1); 26 | 27 | return api; 28 | }; 29 | 30 | // Helper to init routers 31 | function initRouter(env, schema, modules) { 32 | const router = require('./router')(); 33 | modules.forEach(module => { 34 | require(module).init(env, router, schema); 35 | }); 36 | return router; 37 | } 38 | -------------------------------------------------------------------------------- /api/router.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const express = require('express'); 3 | 4 | module.exports = () => { 5 | const router = express.Router(); 6 | 7 | // Define a route handler to resolve promises 8 | const promiseHandler = (origHandler, req, res, next) => { 9 | let result; 10 | try { 11 | result = origHandler.call(router, req, res, next); 12 | } catch (err) { 13 | return handleRouterError(err, res); 14 | } 15 | if (result && typeof result.then === 'function') { 16 | result.then(value => { 17 | res.json( 18 | (value && value.toObject) ? value.toObject() : value 19 | ); 20 | }).catch(err => { 21 | return handleRouterError(err, res); 22 | }); 23 | } else if (result !== undefined) { 24 | res.json(result); 25 | } 26 | }; 27 | 28 | // Wraps router methods to resolve promises 29 | _.each(['get', 'put', 'post', 'delete', 'use'], (method) => { 30 | const origMethod = router[method]; 31 | router[method] = (...args) => { 32 | // Extract original handler 33 | const handler = args.splice(-1, 1)[0]; 34 | if (typeof handler !== 'function') { 35 | throw new Error('Route handler must be a function'); 36 | } 37 | // Replace original handler 38 | args.push(promiseHandler.bind(router, handler)); 39 | origMethod.apply(router, args); 40 | }; 41 | }); 42 | 43 | return router; 44 | }; 45 | 46 | // Graceful error handler 47 | function handleRouterError(err, res) { 48 | const message = err.toString().replace('Error: ', ''); 49 | res.status(err.code || 500).json({ 50 | error: err.code ? message : 'Internal Server Error' 51 | }); 52 | if (!err.code) { 53 | console.log(err); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /api/util.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | const util = module.exports; 4 | 5 | // Remove undefined keys from data, recursive 6 | util.cleanData = (data) => { 7 | const keys = Object.keys(data); 8 | for (let i = 0; i < keys.length; i++) { 9 | const key = keys[i]; 10 | if(data[key] === undefined){ 11 | delete data[key]; 12 | } else if (data[key] instanceof Array) { 13 | for (let x = 0; x < data[key].length; x++) { 14 | if (data[key][x] === undefined) { 15 | data[key].splice(x, 1); 16 | x--; 17 | } else if (data[key][x] && typeof data[key][x] === 'object') { 18 | data[key][x] = util.cleanData(data[key][x]); 19 | } 20 | } 21 | } else if (data[key] && typeof data[key] === 'object') { 22 | data[key] = util.cleanData(data[key]); 23 | } 24 | } 25 | return data; 26 | }; 27 | 28 | // Filter and merge query with request data and extra params 29 | util.filterQuery = (req, extra, query, privateFields) => { 30 | query = Core.util.merge(query, extra, req.data); 31 | if (privateFields) { 32 | privateFields.forEach(field => { 33 | if (req.data[field] !== undefined) { 34 | throw new Error('Query may not specify `' + field + '` using a public key'); 35 | } 36 | }); 37 | } 38 | if (req.data.include !== undefined) { 39 | throw new Error('Query may not specify `include` using a public key'); 40 | } 41 | return util.cleanData(query); 42 | }; 43 | 44 | // Filter data and throw error when some fields are targeted 45 | util.filterData = (data, fields) => { 46 | if (!data) { 47 | return data; 48 | } 49 | if (data instanceof Array) { 50 | data.forEach((val, i) => { 51 | data[i] = util.filterData(val, fields); 52 | }); 53 | return data; 54 | } 55 | if (fields && fields.length) { 56 | const keys = Object.keys(data || {}); 57 | keys.forEach(key => { 58 | if (fields.indexOf(key) === -1) { 59 | throw util.error(400, 'Query may not specify `' + key + '` on this resource'); 60 | } 61 | }); 62 | } 63 | return util.cleanData(data); 64 | }; 65 | 66 | // Ensure some fields are required in data 67 | // Returns errors 68 | util.requireFields = (data, fields, errors) => { 69 | if (!(fields instanceof Array)) { 70 | fields = [fields]; 71 | } 72 | fields.forEach(fieldPath => { 73 | const value = _.get(data, fieldPath); 74 | if (!value) { 75 | errors = errors || {}; 76 | errors[fieldPath] = { 77 | code: 'REQUIRED', 78 | message: 'Required' 79 | }; 80 | } 81 | }); 82 | return errors; 83 | }; 84 | 85 | // Determine whether req.data.fields includes (or excludes) a field 86 | util.fieldsInclude = (req, field, isExplicit) => { 87 | if (!req.body.fields) { 88 | // Include by default or explicitly 89 | return isExplicit ? false : true; 90 | } else if (req.body.fields.indexOf && req.body.fields.indexOf(field) !== -1) { 91 | return true; 92 | } 93 | return false; 94 | }; 95 | 96 | // Create a response error with code 97 | // Message will be returned to the client with status code 98 | util.error = (statusCode, message) => { 99 | let err = new Error(message); 100 | err.code = statusCode; 101 | return err; 102 | }; 103 | -------------------------------------------------------------------------------- /api/v1/account.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const util = require('../util'); 3 | const session = require('./session'); 4 | 5 | const account = module.exports; 6 | 7 | // Init routes 8 | account.init = (env, router, schema) => { 9 | const requireSession = session.require.bind(this, schema); 10 | const requireAccount = account.require.bind(this, schema); 11 | router.get('/account', requireSession, account.get.bind(this, schema)); 12 | router.put('/account', requireSession, requireAccount, account.update.bind(this, schema)); 13 | router.post('/account', requireSession, account.create.bind(this, schema)); 14 | router.post('/account/login', requireSession, account.login.bind(this, schema)); 15 | router.post('/account/logout', requireSession, account.logout.bind(this, schema)); 16 | router.post('/account/recover', requireSession, account.recover.bind(this, schema)); 17 | router.get('/account/orders', requireSession, requireAccount, account.getOrders.bind(this, schema)); 18 | router.get('/account/orders/:id', requireSession, requireAccount, account.getOrderById.bind(this, schema)); 19 | router.get('/account/addresses', requireSession, requireAccount, account.getAddresses.bind(this, schema)); 20 | router.delete('/account/addresses/:id', requireSession, requireAccount, account.removeAddress.bind(this, schema)); 21 | router.get('/account/cards', requireSession, requireAccount, account.getCards.bind(this, schema)); 22 | router.delete('/account/cards/:id', requireSession, requireAccount, account.removeCard.bind(this, schema)); 23 | router.get('/account/reviews', requireSession, requireAccount, account.getReviews.bind(this, schema)); 24 | router.post('/account/reviews', requireSession, requireAccount, account.createReview.bind(this, schema)); 25 | router.get('/account/reviews/:id', requireSession, requireAccount, account.getReviewById.bind(this, schema)); 26 | router.get('/account/review-products', requireSession, requireAccount, account.getReviewProducts.bind(this, schema)); 27 | router.get('/account/credits', requireSession, requireAccount, account.getCredits.bind(this, schema)); 28 | }; 29 | 30 | // Require logged in account 31 | account.require = (schema, req, res, next) => { 32 | if (!req.session.account_id) { 33 | return res.status(400).json({ 34 | error: 'Account must be logged in to access this resource' 35 | }); 36 | } 37 | next(); 38 | }; 39 | 40 | // Get current account 41 | account.get = (schema, req, res) => { 42 | if (!req.session.account_id) { 43 | return null; 44 | } 45 | return schema.get('/accounts/{id}', { 46 | id: req.session.account_id, 47 | }); 48 | }; 49 | 50 | // Update current account 51 | account.update = (schema, req, res) => { 52 | req.body.id = req.session.account_id; 53 | const error = account.filterData(req); 54 | if (error) { 55 | return res.status(400).json(error); 56 | } 57 | return schema.put('/accounts/{id}', req.body); 58 | }; 59 | 60 | // Create new account 61 | account.create = (schema, req, res) => { 62 | if (req.session.account_id) { 63 | return res.status(400).send({ 64 | error: 'Account cannot be logged in before #create' 65 | }); 66 | } 67 | const error = account.filterData(req); 68 | if (error) { 69 | return res.status(400).json(error); 70 | } 71 | req.body.group = 'customers'; // Default group 72 | req.body.type = 'individual'; // Default type 73 | return schema.post('/accounts', req.body).then(result => { 74 | if (result.errors) { 75 | // Allow to register if account exists without a password 76 | const exists = result.errors.email && result.errors.email.code === 'UNIQUE'; 77 | if (exists && req.body.password) { 78 | return schema.get('/accounts/:last', { 79 | email: req.body.email, 80 | password: null 81 | }).then(account => { 82 | if (account) { 83 | return schema.put('/accounts/{id}', { 84 | id: account.id, 85 | password: req.body.password 86 | }).then(result => { 87 | return account.loginSession(schema, req, result); 88 | }); 89 | } 90 | return result; 91 | }); 92 | } 93 | return result; 94 | } 95 | return account.loginSession(schema, req, result); 96 | }); 97 | }; 98 | 99 | // Login account 100 | account.login = (schema, req) => { 101 | return schema.get('/accounts/:login', { 102 | email: req.body.email, 103 | password: req.body.password, 104 | }).then(result => { 105 | if (!result) { 106 | if (req.session.account_id) { 107 | return account.loginSession(schema, req, null); 108 | } 109 | return null; 110 | } 111 | return account.loginSession(schema, req, result); 112 | }); 113 | }; 114 | 115 | // Logout account 116 | account.logout = (schema, req) => { 117 | return schema.put('/:sessions/{id}', { 118 | id: req.sessionID, 119 | account_id: null, 120 | account_group: null, 121 | email: null, 122 | }).then(() => { 123 | return schema.put('/carts/{id}', { 124 | id: req.session.cart_id, 125 | account_id: null 126 | }); 127 | }).return({ 128 | success: true 129 | }); 130 | }; 131 | 132 | // Update session with logged in account 133 | account.loginSession = (schema, req, result) => { 134 | if (!req.sessionID) { 135 | return result; 136 | } 137 | var accountId = result ? result.id : null; 138 | var accountGroup = result ? result.group : null; 139 | var accountEmail = result ? result.email : null; 140 | return schema.put('/:sessions/{id}', { 141 | id: req.sessionID, 142 | account_id: accountId, 143 | account_group: accountGroup, 144 | email: accountEmail, 145 | }).then(() => { 146 | if (req.session.cart_id) { 147 | return schema.put('/carts/{id}', { 148 | id: req.session.cart_id, 149 | account_id: accountId 150 | }); 151 | } 152 | }).then(() => { 153 | return result; 154 | }); 155 | }; 156 | 157 | // Recover account (step 0) 158 | account.recover = (schema, req) => { 159 | if (req.body.email) { 160 | return account.recoverEmail(schema, req); 161 | } else if (req.body.reset_key) { 162 | return account.recoverPassword(schema, req); 163 | } else { 164 | return { 165 | errors: { 166 | email: { 167 | code: 'REQUIRED', 168 | message: 'Missing `email` or `reset_key` to send account recovery notice' 169 | } 170 | } 171 | }; 172 | } 173 | }; 174 | 175 | // Recover account email (step 1) 176 | account.recoverEmail = (schema, req) => { 177 | // Set timeout 1 day in the future by defaultQuery 178 | let date = new Date(); 179 | date.setDate(date.getDate() + 1); 180 | const resetExpired = date.getTime(); 181 | 182 | // Create a unique reset key 183 | const resetKey = 184 | require('crypto') 185 | .createHash('md5') 186 | .update(req.body.email + resetExpired) 187 | .digest('hex'); 188 | 189 | // Build the reset url 190 | let resetUrl; 191 | if (req.body.reset_url && typeof req.body.reset_url === 'string') { 192 | resetUrl = req.body.reset_url; 193 | if (resetUrl.indexOf('{key}') !== -1) { 194 | resetUrl = resetUrl.replace('{key}', resetKey); 195 | } else if (resetUrl.indexOf('{reset_key}') !== -1) { 196 | resetUrl = resetUrl.replace('{reset_key}', resetKey); 197 | } else { 198 | if (resetUrl[resetUrl.length - 1] === '/') { 199 | resetUrl += resetKey; 200 | } else { 201 | resetUrl += '/' + resetKey; 202 | } 203 | } 204 | } else { 205 | return { 206 | errors: { 207 | reset_url: { 208 | code: 'REQUIRED', 209 | message: 'Missing `reset_url` to send account recovery notice' 210 | } 211 | } 212 | }; 213 | } 214 | 215 | return schema.get('/accounts/{email}', { 216 | email: req.body.email 217 | }).then(result => { 218 | if (!result) { 219 | return; 220 | } 221 | 222 | return schema.put('/accounts/{id}', { 223 | id: result.id, 224 | $notify: 'password-reset', 225 | password_reset_url: resetUrl, 226 | password_reset_key: resetKey, 227 | password_reset_expired: resetExpired 228 | }); 229 | }).then(result => { 230 | if (result && (result.errors || result.error)) { 231 | return result; 232 | } 233 | return { 234 | success: true 235 | }; 236 | }); 237 | }; 238 | 239 | // Recover account password (step 2) 240 | account.recoverPassword = (schema, req) => { 241 | if (!req.body.reset_key) { 242 | return { 243 | errors: { 244 | reset_key: { 245 | code: 'REQUIRED', 246 | message: 'Missing `reset_key` to identify account' 247 | } 248 | } 249 | }; 250 | } 251 | if (!req.body.password) { 252 | return { 253 | errors: { 254 | password: { 255 | code: 'REQUIRED', 256 | message: 'Missing `password` to reset account access' 257 | } 258 | } 259 | }; 260 | } 261 | 262 | return schema.get('/accounts/:first', { 263 | password_reset_key: req.body.reset_key 264 | }).then(result => { 265 | if (!result) { 266 | return { 267 | errors: { 268 | reset_key: { 269 | code: 'INVALID', 270 | message: 'Invalid `reset_key`, account not found' 271 | } 272 | } 273 | }; 274 | } 275 | 276 | // Verify key is used before timeout 277 | resetExpired = result.password_reset_expired; 278 | if (!resetExpired || resetExpired <= Date.now()) { 279 | return { 280 | errors: { 281 | reset_key: { 282 | code: 'INVALID', 283 | message: 'Expired `reset_key`, please retry account recovery' 284 | } 285 | } 286 | }; 287 | } 288 | 289 | return schema.put('/accounts/{id}', { 290 | id: result.id, 291 | password: req.body.password, 292 | password_reset_url: null, 293 | password_reset_key: null, 294 | password_reset_expired: null 295 | }); 296 | }).then(result => { 297 | if (result && (result.errors || result.error)) { 298 | return result; 299 | } 300 | if (req.body.login) { 301 | return account.loginSession(schema, req, result); 302 | } 303 | return { 304 | success: true 305 | }; 306 | }); 307 | }; 308 | 309 | // Filter account data 310 | account.filterData = (req) => { 311 | try { 312 | req.body = util.filterData(req.body, [ 313 | 'id', 314 | 'shipping', 315 | 'billing', 316 | 'name', 317 | 'first_name', 318 | 'last_name', 319 | 'email', 320 | 'password' 321 | // TODO: more fields 322 | ]); 323 | } catch (err) { 324 | return { error: err.toString() }; 325 | } 326 | }; 327 | 328 | // Get account orders list 329 | account.getOrders = (schema, req) => { 330 | const query = req.query || {}; 331 | return schema.get('/orders', { 332 | account_id: req.session.account_id, 333 | fields: query.fields, 334 | limit: query.limit, 335 | page: query.page, 336 | sort: query.sort, 337 | expand: 'items.product, items.variant, items.bundle_items.product, items.bundle_items.variant', 338 | }); 339 | }; 340 | 341 | // Get account order by ID 342 | account.getOrderById = (schema, req) => { 343 | const query = req.query || {}; 344 | return schema.get('/orders/{id}', { 345 | account_id: req.session.account_id, 346 | id: req.params.id, 347 | fields: query.fields, 348 | expand: 'items.product, items.variant, items.bundle_items.product, items.bundle_items.variant', 349 | }); 350 | }; 351 | 352 | // Get account addresses list 353 | account.getAddresses = (schema, req) => { 354 | const query = req.query || {}; 355 | return schema.get('/accounts/{id}/addresses', { 356 | id: req.session.account_id, 357 | fields: query.fields, 358 | limit: query.limit, 359 | page: query.page, 360 | where: { 361 | active: true, 362 | }, 363 | }); 364 | }; 365 | 366 | // Remove an account address 367 | account.removeAddress = (schema, req) => { 368 | return schema.put('/accounts/{id}/addresses/{address_id}', { 369 | id: req.session.account_id, 370 | address_id: req.params.id, 371 | active: false, 372 | }).return({ 373 | id: req.params.id, 374 | success: true 375 | }); 376 | }; 377 | 378 | // Get account credit cards list 379 | account.getCards = (schema, req) => { 380 | const query = req.query || {}; 381 | return schema.get('/accounts/{id}/cards', { 382 | id: req.session.account_id, 383 | fields: query.fields, 384 | limit: query.limit, 385 | page: query.page, 386 | where: { 387 | active: true, 388 | }, 389 | }); 390 | }; 391 | 392 | // Remove an account card 393 | account.removeCard = (schema, req) => { 394 | return schema.put('/accounts/{id}/cards/{card_id}', { 395 | id: req.session.account_id, 396 | card_id: req.params.id, 397 | active: false, 398 | }).return({ 399 | id: req.params.id, 400 | success: true 401 | }); 402 | }; 403 | 404 | // Get account reviews list 405 | account.getReviews = (schema, req) => { 406 | const query = req.query || {}; 407 | return schema.get('/products:reviews', { 408 | account_id: req.session.account_id, 409 | fields: query.fields, 410 | limit: query.limit, 411 | page: query.page, 412 | expand: 'parent', 413 | }); 414 | }; 415 | 416 | // Get account review by ID 417 | account.getReviewById = (schema, req) => { 418 | const query = req.query || {}; 419 | return schema.get('/products:reviews/{id}', { 420 | account_id: req.session.account_id, 421 | id: req.params.id, 422 | fields: query.fields, 423 | expand: 'parent', 424 | }); 425 | }; 426 | 427 | // Create a product review 428 | account.createReview = (schema, req) => { 429 | const data = { 430 | account_id: req.session.account_id, 431 | parent_id: req.body.parent_id, 432 | name: req.body.name, 433 | title: req.body.title, 434 | comments: req.body.comments, 435 | rating: req.body.rating, 436 | }; 437 | return schema.get('/products:reviews/:last', { 438 | account_id: req.session.account_id, 439 | parent_id: req.body.parent_id, 440 | }).then(ex => { 441 | if (ex) { 442 | if (!ex.approved) { 443 | // Update not-yet-approved review 444 | data.id = ex.id; 445 | return schema.put('/products:reviews/{id}', data).then(review => { 446 | return account.getReviewById(schema, { 447 | session: req.session, 448 | params: { id: ex.id }, 449 | }); 450 | }); 451 | } else { 452 | return { error: 'You have already reviewed this product' }; 453 | } 454 | } 455 | return schema.post('/products:reviews', data); 456 | }); 457 | }; 458 | 459 | // Get products reviewable by account 460 | account.getReviewProducts = (schema, req) => { 461 | const query = req.query || {}; 462 | return schema.get('/orders', { 463 | account_id: req.session.account_id, 464 | fields: 'items.product_id', 465 | limit: null, 466 | }).then(orders => { 467 | return _.uniq( 468 | _.flatten( 469 | orders.results.map(order => 470 | order.items.map(item => item.product_id) 471 | ) 472 | ) 473 | ); 474 | }).then(productIds => { 475 | return schema.get('/products', { 476 | id: { $in: productIds }, 477 | fields: 'name, images', 478 | limit: null, 479 | }); 480 | }).then(products => { 481 | return products.results.map(product => product.toObject()); 482 | }); 483 | }; 484 | 485 | // Get account credits list 486 | account.getCredits = (schema, req) => { 487 | const query = req.query || {}; 488 | return schema.get('/accounts/{id}/credits', { 489 | id: req.session.account_id, 490 | fields: query.fields, 491 | limit: query.limit, 492 | page: query.page, 493 | }); 494 | }; 495 | -------------------------------------------------------------------------------- /api/v1/cart.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const Promise = require('bluebird'); 3 | const util = require('../util'); 4 | const session = require('./session'); 5 | const account = require('./account'); 6 | 7 | const cart = module.exports; 8 | 9 | // Init routes 10 | cart.init = (env, router, schema) => { 11 | const requireSession = session.require.bind(this, schema); 12 | router.get('/cart', requireSession, cart.get.bind(this, schema)); 13 | router.put('/cart', requireSession, cart.update.bind(this, schema)); 14 | router.post('/cart', requireSession, cart.create.bind(this, schema)); 15 | router.post('/cart/add-item', requireSession, cart.addItem.bind(this, schema)); 16 | router.post('/cart/remove-item', requireSession, cart.removeItem.bind(this, schema)); 17 | router.post('/cart/apply-coupon', requireSession, cart.applyCoupon.bind(this, schema)); 18 | router.get('/cart/shipment-rating', requireSession, cart.shipmentRating.bind(this, schema)); 19 | router.post('/cart/checkout', requireSession, cart.checkout.bind(this, schema)); 20 | }; 21 | 22 | cart.get = (schema, req) => { 23 | return schema.get('/carts/{id}', { 24 | id: req.session.cart_id, 25 | expand: 'items.product, items.variant, items.bundle_items.product, items.bundle_items.variant', 26 | }); 27 | }; 28 | 29 | cart.update = (schema, req) => { 30 | return cart.ensureExists(schema, req).then(() => { 31 | var cartData = { 32 | cart_id: req.session.cart_id, 33 | $promotions: true, 34 | }; 35 | if (req.body.items) { 36 | cartData.items = util.filterData(req.body.items, [ 37 | 'id', 38 | 'product_id', 39 | 'variant_id', 40 | 'options', 41 | 'quantity' 42 | ]); 43 | } 44 | var accountEmail = req.body.account && req.body.account.email; 45 | if (req.session.account_id) { 46 | cartData.account_id = req.session.account_id; 47 | } else if (accountEmail) { 48 | cartData.account_id = null; 49 | cartData.account = { email: accountEmail }; 50 | } 51 | if (req.body.shipping !== undefined) { 52 | cartData.shipping = cart.sanitizeShipping(req.body.shipping); 53 | } 54 | if (req.body.billing !== undefined) { 55 | cartData.billing = cart.sanitizeBilling(req.body.billing); 56 | } 57 | if (req.body.coupon_code !== undefined) { 58 | cartData.coupon_code = req.body.coupon_code; 59 | } 60 | if (req.body.shipment_rating !== undefined) { 61 | cartData.shipment_rating = req.body.shipment_rating; 62 | } 63 | return schema.put('/carts/{cart_id}', cartData).then(result => { 64 | // Update anonymous account if created new 65 | if (accountEmail && result.account.id && !result.account.name) { 66 | return schema.put('/accounts/{account_id}', { 67 | account_id: result.account.id, 68 | first_name: cartData.shipping.first_name, 69 | last_name: cartData.shipping.last_name, 70 | shipping: cartData.shipping, 71 | billing: cartData.billing, 72 | }); 73 | } 74 | }); 75 | }).then(() => { 76 | return cart.get(schema, req); 77 | }); 78 | }; 79 | 80 | cart.create = (schema, req) => { 81 | return cart.update(schema, req); 82 | }; 83 | 84 | // Add item to cart 85 | cart.addItem = (schema, req) => { 86 | if (!req.body.product_id) { 87 | throw util.error(400, 'Missing parameter `product_id`'); 88 | } 89 | return cart.ensureExists(schema, req).then(result => { 90 | return cart.validateProduct(schema, req.body).then(() => { 91 | req.body.cart_id = req.session.cart_id; 92 | return schema.post('/carts/{cart_id}/items', ( 93 | util.filterData(req.body, [ 94 | 'cart_id', 95 | 'product_id', 96 | 'variant_id', 97 | 'options', 98 | 'quantity' 99 | ]) 100 | )).then(result => { 101 | return cart.get(schema, req); 102 | }); 103 | }); 104 | }); 105 | }; 106 | 107 | // Remove item from cart 108 | cart.removeItem = (schema, req) => { 109 | if (req.body.item_id === null || req.body.item_id === undefined) { 110 | return { 111 | errors: { 112 | item_id: { 113 | code: 'REQUIRED', 114 | message: 'Item ID or index must be specified' 115 | } 116 | } 117 | }; 118 | } 119 | return schema.delete('/carts/{cart_id}/items/{item_id}', { 120 | cart_id: req.session.cart_id, 121 | item_id: req.body.item_id, 122 | }).then(() => { 123 | return cart.get(schema, req); 124 | }); 125 | }; 126 | 127 | // Apply a coupon code 128 | cart.applyCoupon = (schema, req) => { 129 | return schema.put('/carts/{cart_id}', { 130 | cart_id: req.session.cart_id, 131 | coupon_code: req.body.code || req.body.coupon_code || null, 132 | }).then(result => { 133 | if (result.errors && result.errors.coupon_code) { 134 | return result; 135 | } 136 | return cart.get(schema, req); 137 | }); 138 | }; 139 | 140 | // Ensure shipping fields are sane 141 | cart.sanitizeShipping = (shipping) => { 142 | // Restrict certain props 143 | delete shipping.service_name; 144 | delete shipping.price; 145 | // First and last vs full name 146 | if (shipping.first_name || shipping.last_name) { 147 | shipping.name = shipping.first_name + ' ' + shipping.last_name; 148 | } 149 | shipping = util.filterData(shipping, [ 150 | 'name', 151 | 'first_name', 152 | 'last_name', 153 | 'address1', 154 | 'address2', 155 | 'company', 156 | 'city', 157 | 'state', 158 | 'zip', 159 | 'country', 160 | 'phone', 161 | 'account_address_id', 162 | 'service', 163 | ]); 164 | return shipping; 165 | }, 166 | 167 | // Ensure billing fields are sane 168 | cart.sanitizeBilling = (billing) => { 169 | // Restrict certain props 170 | delete billing.method; 171 | // First and last vs full name 172 | if (billing.first_name || billing.last_name) { 173 | billing.name = billing.first_name + ' ' + billing.last_name; 174 | } 175 | billing = util.filterData(billing, [ 176 | 'name', 177 | 'first_name', 178 | 'last_name', 179 | 'address1', 180 | 'address2', 181 | 'company', 182 | 'city', 183 | 'state', 184 | 'zip', 185 | 'country', 186 | 'phone', 187 | 'card', 188 | 'account_card_id' 189 | ]); 190 | // Default to credit card billing 191 | if (!billing.method) { 192 | billing.method = 'card'; 193 | } 194 | if (billing.card) { 195 | billing.card = util.filterData(billing.card, [ 196 | 'token', 197 | 'brand', 198 | 'last4', 199 | 'exp_month', 200 | 'exp_year', 201 | 'address_check', 202 | 'zip_check', 203 | 'cvc_check', 204 | 'stripe_token', 205 | ]); 206 | } 207 | return billing; 208 | }, 209 | 210 | // Get cart shipment rating 211 | cart.shipmentRating = (schema, req) => { 212 | return cart.ensureExists(schema, req).then(() => { 213 | return schema.put('/carts/{cart_id}', { 214 | cart_id: req.session.cart_id, 215 | shipping: req.body.shipping && cart.sanitizeShipping(req.body.shipping), 216 | shipment_rating: null, 217 | }); 218 | }).then(result => { 219 | if (!result.items || !result.items.length) { 220 | return { 221 | errors: { 222 | items: { 223 | code: 'REQUIRED', 224 | message: 'Cart must have at least one item' 225 | } 226 | } 227 | }; 228 | } 229 | const errors = util.requireFields(result, [ 230 | 'shipping.country' 231 | ]); 232 | if (errors) { 233 | return { errors: errors }; 234 | } 235 | return result.shipment_rating; 236 | }); 237 | }; 238 | 239 | // Perform cart checkout 240 | cart.checkout = (schema, req) => { 241 | return cart.ensureExists(schema, req).then(() => { 242 | return cart.update(schema, req); 243 | }).then(result => { 244 | var errors = this.validateCheckout(result); 245 | if (errors) { 246 | return errors; 247 | } 248 | return schema.post('/orders', { 249 | cart_id: req.session.cart_id 250 | }); 251 | }).then(order => { 252 | if (!order || order.errors) { 253 | return order; 254 | } 255 | // Remove cart ID from session 256 | return schema.put('/:sessions/{id}', { 257 | id: req.sessionID, 258 | cart_id: null, 259 | order_id: order.id, 260 | }).then(session => { 261 | return account.getOrderById(schema, { 262 | session: { 263 | account_id: order.account_id, 264 | }, 265 | params: { 266 | id: session.order_id, 267 | }, 268 | }); 269 | }); 270 | }); 271 | }; 272 | 273 | cart.validateCheckout = (data) => { 274 | if (!data) { 275 | return null; 276 | } 277 | if (!data.items || !data.items.length) { 278 | return { 279 | errors: { 280 | items: { 281 | code: 'REQUIRED', 282 | message: 'Cart must have at least one item' 283 | } 284 | } 285 | }; 286 | } 287 | 288 | let errors = util.requireFields(data, [ 289 | 'account_id', 290 | 'billing.name', 291 | 'billing.method' 292 | ]); 293 | if (errors && errors.account_id) { 294 | errors.account_id.message = 'Customer must be logged in'; 295 | } 296 | if (data.billing && data.billing.method === 'card') { 297 | errors = util.requireFields(data, [ 298 | 'billing.card.token' 299 | ], errors); 300 | } 301 | if (data.shipment_delivery) { 302 | errors = util.requireFields(data, [ 303 | 'shipping.name', 304 | 'shipping.address1', 305 | 'shipping.city', 306 | 'shipping.country', 307 | 'shipping.service' 308 | ], errors); 309 | } 310 | const country = data.shipping && data.shipping.country && data.shipping.country.toUpperCase(); 311 | if (country === 'US' || country === 'CA') { 312 | errors = util.requireFields(data, [ 313 | 'shipping.state' 314 | ], errors); 315 | } 316 | return errors ? { errors: errors } : null; 317 | }; 318 | 319 | // Ensure a cart exists before operating on it 320 | cart.ensureExists = (schema, req) => { 321 | return Promise.try(() => { 322 | // Get existing cart 323 | if (req.session.cart_id) { 324 | return schema.get('/carts/{cart_id}', { 325 | cart_id: req.session.cart_id 326 | }); 327 | } 328 | }).then(result => { 329 | if (result) { 330 | return result; 331 | } 332 | // Create new cart 333 | return schema.post('/carts', { 334 | account_id: req.session.account_id 335 | }).then(result => { 336 | if (!result || result.errors) { 337 | throw new Error('Unable to create cart'); 338 | } 339 | // Put cart ID in session 340 | return schema.put('/:sessions/{id}', { 341 | id: req.sessionID, 342 | cart_id: result.id 343 | }).then(() => result); 344 | }); 345 | }).then(result => { 346 | req.session.cart_id = result.id; 347 | return result; 348 | }); 349 | }; 350 | 351 | // Validate a cart product before adding it 352 | cart.validateProduct = (schema, data) => { 353 | return schema.get('/products/{product_id}', { 354 | product_id: data.product_id, 355 | active: true, 356 | include: { 357 | variant: { 358 | url: '/products:variants/'+data.variant_id, 359 | data: { 360 | parent_id: data.product_id, 361 | active: true 362 | } 363 | } 364 | } 365 | }).then(product => { 366 | product = product || {}; 367 | var productId = product.id; 368 | var variantId = product.variant && product.variant.id; 369 | if (!product.id) { 370 | throw util.error(400, 'Product not found'); 371 | } 372 | if (product.delivery === 'subscription') { 373 | throw util.error(400, 'Subscription products cannot be added to a cart'); 374 | } 375 | if (product.variable && !data.variant_id) { 376 | // Return first variant 377 | return schema.get('/products:variants/:last', { 378 | product_id: data.product_id, 379 | active: true, 380 | fields: 'id', 381 | }).then(variant => { 382 | if (variant) { 383 | data.variant_id = variant.id; 384 | } 385 | }); 386 | } 387 | if (data.variant_id && !product.variant) { 388 | throw util.error(400, 'Variant not found for this product (' + product.name + ')'); 389 | } 390 | if (data.variant_id && !product.variable) { 391 | throw util.error(400, 'Product is not variable (' + product.name + ')'); 392 | } 393 | // Valid 394 | data.product_id = productId; 395 | data.variant_id = variantId; 396 | }); 397 | }; 398 | -------------------------------------------------------------------------------- /api/v1/categories.js: -------------------------------------------------------------------------------- 1 | const Promise = require('bluebird'); 2 | const util = require('../util'); 3 | const cache = require('../cache'); 4 | const products = require('./products'); 5 | 6 | const categories = module.exports; 7 | 8 | // Init routes 9 | categories.init = (env, router, schema) => { 10 | router.get('/categories/:id', cache(env), categories.getById.bind(this, schema)); 11 | router.get('/categories/:id/children', cache(env), categories.getChildren.bind(this, schema)); 12 | router.get('/categories/:id/products', cache(env), categories.getAllProducts.bind(this, schema)); 13 | }; 14 | 15 | // Get category by ID 16 | categories.getById = (schema, req) => { 17 | return schema.get('/categories/{id}', { 18 | id: req.params.id, 19 | where: categories.defaultQuery(req), 20 | sort: categories.defaultSort(req), 21 | }); 22 | }; 23 | 24 | // Get category children 25 | categories.getChildren = (schema, req) => { 26 | return schema.get('/categories/{id}/children', { 27 | id: req.params.id, 28 | where: categories.defaultQuery(req), 29 | sort: categories.defaultSort(req), 30 | include: categories.filterIncludeQuery(req), 31 | }); 32 | }; 33 | 34 | // Get all nested category products 35 | categories.getAllProducts = (schema, req) => { 36 | return schema.get('/categories/{id}', { 37 | id: req.params.id, 38 | fields: 'id' 39 | }).then(category => { 40 | if (!category) { 41 | return null; 42 | } 43 | return categories.getAllChildIdsRecursive(schema, req, [ category.id ]).then(allCategoryIds => { 44 | if (!allCategoryIds.length) { 45 | return null; 46 | } 47 | return schema.get('/products', { 48 | limit: req.query.limit || 25, 49 | page: req.query.page || 1, 50 | $or: allCategoryIds.map(categoryId => { 51 | return { 52 | 'category_index.id': categoryId, 53 | }; 54 | }), 55 | where: products.defaultQuery(req), 56 | sort: categories.nestedProductSort(req, category.id), 57 | }); 58 | }); 59 | }); 60 | }; 61 | 62 | // Get child category IDs recursively 63 | categories.getAllChildIdsRecursive = (schema, req, parentIds, ids) => { 64 | ids = ids || []; 65 | return schema.get('/categories', { 66 | id: { $in: parentIds }, 67 | fields: 'id', 68 | where: categories.defaultQuery(req), 69 | include: { 70 | children: { 71 | url: '/categories', 72 | params: { 73 | parent_id: 'id' 74 | }, 75 | data: { 76 | fields: 'id', 77 | limit: null 78 | } 79 | } 80 | } 81 | }).then(result => { 82 | if (!result || !result.results.length) { 83 | return ids; 84 | } 85 | return Promise.all( 86 | result.results.map(category => { 87 | ids.push(category.id); 88 | if (category.children.results.length) { 89 | const nextIds = category.children.results.map(child => child.id); 90 | return new Promise(resolve => { 91 | categories.getAllChildIdsRecursive(schema, req, nextIds, ids).then(resolve); 92 | }); 93 | } 94 | }) 95 | ).then(() => { 96 | return ids; 97 | }); 98 | }); 99 | }; 100 | 101 | // Get a category 102 | 103 | // Default query 104 | categories.defaultQuery = (req) => { 105 | return { 106 | active: true, 107 | navigation: req.query.navigation || undefined, 108 | } 109 | }; 110 | 111 | // Default sort 112 | categories.defaultSort = (req) => { 113 | return { 114 | sort: 'sort ascending', 115 | } 116 | }; 117 | 118 | // Sorting for nested category products 119 | categories.nestedProductSort = (req, categoryId) => { 120 | const sort = req.query.sort; 121 | switch(sort) { 122 | case undefined: 123 | case 'featured': 124 | return `category_index.sort.${categoryId} asc`; 125 | default: 126 | return sort; 127 | } 128 | }; 129 | 130 | // Filter include query for categories 131 | categories.filterIncludeQuery = (req) => { 132 | let include = {}; 133 | if (req.query.depth) { 134 | include = categories.filterIncludeQueryDepth(include, req); 135 | } 136 | return include; 137 | }; 138 | 139 | // Filter depth query to include nested child categories 140 | categories.filterIncludeQueryDepth = (include, req) => { 141 | // Depth query specifies how deep the children should fetch 142 | let depth = req.query.depth; 143 | if (depth > 1) { 144 | if (depth > 5) { 145 | throw util.error(400, 'Too much depth'); 146 | } 147 | let deepInclude = include; 148 | while (--depth) { 149 | deepInclude.children = {}; 150 | deepInclude.children.url = '/categories', 151 | deepInclude.children.params = { parent_id: 'id' }; 152 | deepInclude.children.data = { include: {} }; 153 | // Recursion here 154 | deepInclude = deepInclude.children.data.include; 155 | } 156 | } 157 | return include; 158 | }; 159 | -------------------------------------------------------------------------------- /api/v1/contacts.js: -------------------------------------------------------------------------------- 1 | const util = require('../util'); 2 | 3 | const contacts = module.exports; 4 | 5 | // Init routes 6 | contacts.init = (env, router, schema) => { 7 | router.post('/contacts/subscribe', contacts.subscribe.bind(this, schema)); 8 | router.post('/contacts/unsubscribe', contacts.unsubscribe.bind(this, schema)); 9 | }; 10 | 11 | // Subscribe contact to list 12 | contacts.subscribe = (schema, req) => { 13 | contacts.sanitizeEmailOptinLists(req, true); 14 | let data = util.filterData(req.body, [ 15 | 'first_name', 16 | 'last_name', 17 | 'email', 18 | 'email_optin', 19 | 'email_optin_lists' 20 | ]); 21 | data.email_optin = true; 22 | return schema.put('/contacts/{email}', data).then(result => { 23 | if (result && result.errors) { 24 | return result; 25 | } 26 | return { success: true }; 27 | }); 28 | }; 29 | 30 | // Unsubscribe contact from list 31 | contacts.unsubscribe = (schema, req) => { 32 | contacts.sanitizeEmailOptinLists(req, false); 33 | let data = util.filterData(req.body, [ 34 | 'email', 35 | 'email_optin_lists' 36 | ]); 37 | return schema.put('/contacts/{email}', data).then(result => { 38 | if (result && result.errors) { 39 | return result; 40 | } 41 | return { success: true }; 42 | }); 43 | }; 44 | 45 | contacts.sanitizeEmailOptinLists = (req, optin) => { 46 | let reqLists = req.body.email_optin_lists; 47 | if (!reqLists) { 48 | if (!optin) { 49 | req.body.email_optin = false; 50 | req.body.$set = req.body.$set || {}; 51 | req.body.$set.email_optin_lists = {}; 52 | } 53 | return; 54 | } 55 | 56 | let listArray; 57 | if (typeof reqLists === 'string') { 58 | listArray = reqLists.split(/\s*,\s*/); 59 | } else if (reqLists instanceof Array) { 60 | listArray = reqLists; 61 | } 62 | 63 | if (listArray) { 64 | reqLists = {}; 65 | for (let i = 0; i < listArray.length; i++) { 66 | const list = listArray[i]; 67 | if (typeof list === 'string') { 68 | reqLists[list] = !!optin; 69 | } 70 | } 71 | } else if (!optin) { 72 | throw util.error(400, 'Unsubscribe expects `email_optin_lists` to be an array of lists to remove'); 73 | } 74 | 75 | req.body.email_optin_lists = reqLists; 76 | }; 77 | -------------------------------------------------------------------------------- /api/v1/pages.js: -------------------------------------------------------------------------------- 1 | const cache = require('../cache'); 2 | 3 | const pages = module.exports; 4 | 5 | // Init routes 6 | pages.init = (env, router, schema) => { 7 | router.get('/pages/:id', cache(env), pages.getById.bind(this, schema)); 8 | router.get('/pages/:id/articles', cache(env), pages.getArticles.bind(this, schema)); 9 | router.get('/pages/:id/articles/:article_id', cache(env), pages.getArticlesById.bind(this, schema)); 10 | }; 11 | 12 | // Get page by ID 13 | pages.getById = (schema, req) => { 14 | return schema.get('/pages/{id}', { 15 | id: req.params.id, 16 | where: pages.defaultQuery(req), 17 | include: req.query.include === 'articles' ? { 18 | articles: { 19 | url: '/pages:articles', 20 | params: { 21 | parent_id: 'id' 22 | }, 23 | data: pages.defaultQuery(req) 24 | } 25 | } : null 26 | }); 27 | }; 28 | 29 | // Get page articles 30 | pages.getArticles = (schema, req) => { 31 | return schema.get('/pages/{id}/articles', { 32 | id: req.params.id, 33 | where: pages.defaultQuery(req), 34 | }); 35 | }; 36 | 37 | // Page page article by ID 38 | pages.getArticlesById = (schema, req) => { 39 | return schema.get('/pages/{id}/articles/{article_id}', { 40 | id: req.params.id, 41 | article_id: req.params.article_id, 42 | where: pages.defaultQuery(req), 43 | }); 44 | }; 45 | 46 | // Default query 47 | pages.defaultQuery = () => { 48 | $or: [{ 49 | date_published: null 50 | }, { 51 | date_published: { 52 | $lt: Date.now() 53 | } 54 | }] 55 | }; 56 | -------------------------------------------------------------------------------- /api/v1/products.js: -------------------------------------------------------------------------------- 1 | const util = require('../util'); 2 | const cache = require('../cache'); 3 | 4 | const products = module.exports; 5 | 6 | // Init routes 7 | products.init = (env, router, schema) => { 8 | router.get('/products', cache(env), products.get.bind(this, schema)); 9 | router.get('/products/:id', cache(env), products.getById.bind(this, schema)); 10 | router.get('/products/:id/reviews', cache(env), products.getReviews.bind(this, schema)); 11 | }; 12 | 13 | // Get many products 14 | products.get = (schema, req) => { 15 | if (req.query.category) { 16 | return products.getByCategory(schema, req); 17 | } 18 | return schema.get('/products', { 19 | where: products.defaultQuery(req), 20 | limit: req.query.limit || 15, 21 | page: req.query.page || 1 22 | }); 23 | }; 24 | 25 | // Get one product 26 | products.getById = (schema, req) => { 27 | return schema.get('/products/{id}', { 28 | id: req.params.id, 29 | where: products.defaultQuery(req) 30 | }); 31 | }; 32 | 33 | // Get products 34 | products.getByCategory = (schema, req) => { 35 | return schema.get('/categories/{id}', { 36 | id: req.query.category, 37 | fields: 'id' 38 | }).then(category => { 39 | if (!category) { 40 | throw util.error(400, 'Category not found `' + req.query.category + '`'); 41 | } 42 | return schema.get('/products', { 43 | 'category_index.id': category.id, 44 | sort: 'category_index.sort.' + category.id, 45 | where: products.defaultQuery(req), 46 | limit: req.query.limit || 15, 47 | page: req.query.page || 1 48 | }); 49 | }); 50 | }; 51 | 52 | // Get product reviews 53 | products.getReviews = (schema, req) => { 54 | const query = req.query || {}; 55 | return schema.get('/products/{id}/reviews', { 56 | id: req.params.id, 57 | fields: query.fields, 58 | limit: query.limit, 59 | page: query.page, 60 | approved: true, 61 | }); 62 | }; 63 | 64 | // Default query 65 | products.defaultQuery = () => { 66 | active: true 67 | }; 68 | -------------------------------------------------------------------------------- /api/v1/session.js: -------------------------------------------------------------------------------- 1 | const Promise = require('bluebird'); 2 | 3 | const session = module.exports; 4 | 5 | const RESTRICTED_FIELDS = [ 6 | 'cart_id', 7 | 'account_id', 8 | 'account_group' 9 | ]; 10 | 11 | // Init routes 12 | session.init = (env, router, schema) => { 13 | const requireSession = session.require.bind(this, schema); 14 | router.get('/session', requireSession, session.get.bind(this, schema)); 15 | router.put('/session', requireSession, session.update.bind(this, schema)); 16 | }; 17 | 18 | // Require session and set to req.session 19 | session.require = (schema, req, res, next) => { 20 | session.getByRequest(schema, req).then((session) => { 21 | if (session) { 22 | req.session = session; 23 | return next(); 24 | } 25 | res.status(400).json({ 26 | error: 'Session ID is must be defined in order to access this resource' 27 | }); 28 | }); 29 | }; 30 | 31 | // Get session data 32 | session.get = (schema, req) => { 33 | return Promise.resolve(req.session); 34 | }; 35 | 36 | // Update session data 37 | session.update = (schema, req, res) => { 38 | const error = session.validateData(req); 39 | if (error) { 40 | return res.status(400).json(error); 41 | } 42 | req.body.id = req.sessionID; 43 | return schema.put('/:sessions/{id}', req.body).then(result => { 44 | return result; 45 | }); 46 | }; 47 | 48 | // Get or create session from req.sessionID 49 | session.getByRequest = (schema, req) => { 50 | return Promise.try(() => { 51 | if (!req.sessionID) { 52 | return; 53 | } 54 | return schema.get('/:sessions/{id}', { 55 | id: req.sessionID, 56 | }).then(result => { 57 | if (result) { 58 | return result; 59 | } 60 | return schema.put('/:sessions/{id}', { 61 | id: req.sessionID, 62 | }); 63 | }).then(result => { 64 | // Map client info to session 65 | if (result) { 66 | return schema.get('/:clients/:self').then(self => { 67 | if (self) { 68 | if (result.toObject) { 69 | result = result.toObject(); 70 | } 71 | result.currency = result.currency || self.currency; 72 | result.locale = self.locale; 73 | result.timezone = self.timezone; 74 | } 75 | }).then(() => { 76 | return session.getCurrencies(schema).then(rating => { 77 | result.currencies = rating && rating.toObject(); 78 | }); 79 | }).then(() => { 80 | return result; 81 | }); 82 | } 83 | }); 84 | }); 85 | }; 86 | 87 | // Get currency rating 88 | session.getCurrencies = (schema) => { 89 | return Promise.try(() => { 90 | if (session.currencies) { 91 | return session.currencies; 92 | } 93 | return schema.get('/:currencies').then(result => { 94 | // Cache for 1 hour 95 | session.currencies = result; 96 | setTimeout(() => { 97 | delete session.currencies; 98 | }, 3600000); 99 | return result; 100 | }); 101 | }); 102 | }; 103 | 104 | // Validate restricted session fields 105 | session.validateData = (req) => { 106 | for (var i = 0; i < RESTRICTED_FIELDS.length; i++) { 107 | var field = RESTRICTED_FIELDS[i]; 108 | if (req.body[field] !== undefined) { 109 | return { error: 'Session `' + field + '` is restricted' }; 110 | } 111 | } 112 | }; 113 | -------------------------------------------------------------------------------- /api/v1/slug.js: -------------------------------------------------------------------------------- 1 | const util = require('../util'); 2 | const cache = require('../cache'); 3 | const pages = require('./pages'); 4 | const products = require('./products'); 5 | const categories = require('./categories'); 6 | 7 | const slug = module.exports; 8 | 9 | // Init routes 10 | slug.init = (env, router, schema) => { 11 | router.get('/slug/:id*', cache(env), slug.get.bind(this, schema)); 12 | }; 13 | 14 | // Find a resource by slug in order of priority 15 | slug.get = (schema, req) => { 16 | // Sub category /can/be/nested 17 | if (req.params[0].length) { 18 | req.params.id = req.params[0].split('/').pop(); 19 | return categories.getById(schema, req).then(result => { 20 | return slug.response('category', result); 21 | }); 22 | } 23 | // Page 24 | return pages.getById(schema, req).then(result => { 25 | if (result) { 26 | return slug.response('page', result); 27 | } 28 | // Product 29 | return products.getById(schema, req).then(result => { 30 | if (result) { 31 | return slug.response('product', result); 32 | } 33 | // Category 34 | return categories.getById(schema, req).then(result => { 35 | if (result) { 36 | return slug.response('category', result); 37 | } 38 | // Not found 39 | return null; 40 | }); 41 | }); 42 | }); 43 | }; 44 | 45 | // Prepare slug response 46 | slug.response = (type, result) => { 47 | if (result && result.toObject) { 48 | return { type: type, data: result.toObject() }; 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /env.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const util = require('util'); 3 | const nconf = require('nconf'); 4 | 5 | const env = module.exports; 6 | 7 | const vars = { 8 | // Do not define NODE_ENV for Heroku 9 | NODE_ENV: { 10 | required: true, 11 | export: true, 12 | }, 13 | // Do not define PORT for Heroku 14 | PORT: { 15 | required: true, 16 | export: true, 17 | type: Number, 18 | }, 19 | 20 | // Server 21 | FORCE_SSL: { 22 | required: false, 23 | }, 24 | 25 | // API Caching 26 | CACHE_SECONDS: { 27 | required: false, 28 | export: true, 29 | type: Number, 30 | }, 31 | 32 | // Schema 33 | SCHEMA_CLIENT_ID: { 34 | required: true, 35 | export: true, 36 | }, 37 | SCHEMA_CLIENT_KEY: { 38 | required: true, 39 | export: true, 40 | redacted: true, 41 | }, 42 | }; 43 | _.each(vars, (envProps, envName) => { 44 | let envVal = nconf.get(envName) || '[UNDEFINED]'; 45 | 46 | // Required 47 | if (envProps.required && envVal === '[UNDEFINED]') { 48 | // eslint-disable-next-line no-console 49 | console.error( 50 | `├── Missing ENV Variable: ${envName}. Check your .env file.` 51 | ); 52 | process.exit(1); 53 | } 54 | 55 | // Cast to Number 56 | if (envProps.type === Number) { 57 | envVal = Number(envVal); 58 | } 59 | 60 | // Export 61 | if (envProps.export) { 62 | env[envName] = envVal; 63 | } 64 | 65 | // Redacted 66 | if (envProps.redacted) { 67 | envVal = '[REDACTED]'; 68 | } 69 | 70 | // eslint-disable-next-line no-console 71 | console.log(`├── ${envName}=${envVal} ──┤`); 72 | }); 73 | 74 | // Dump for debugging 75 | global.dump = (...args) => { 76 | if (env.NODE_ENV !== 'development') return; 77 | try { 78 | for (var i in args) { 79 | console.log( 80 | util.inspect(args[i], { 81 | showHidden: true, 82 | colors: true, 83 | depth: 7, 84 | }) 85 | ); 86 | } 87 | } catch (e) { 88 | throw new Error('Dump Error: ' + e); 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "restartable": "rs", 3 | "ignore": [ 4 | ".git", 5 | "node_modules/**/node_modules", 6 | "test" 7 | ], 8 | "ext": "js json" 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "schema-api", 3 | "version": "1.0.1", 4 | "private": true, 5 | "engines": { 6 | "node": "6.5.0", 7 | "npm": "3.10.3" 8 | }, 9 | "dependencies": { 10 | "apicache": "0.1.0", 11 | "bluebird": "3.4.6", 12 | "body-parser": "1.15.2", 13 | "compression": "1.6.2", 14 | "cors": "2.8.0", 15 | "debug": "2.2.0", 16 | "dotenv": "2.0.0", 17 | "express": "4.14.0", 18 | "express-enforces-ssl": "1.1.0", 19 | "express-promise": "0.4.0", 20 | "lodash": "4.15.0", 21 | "morgan": "1.7.0", 22 | "nconf": "0.8.4", 23 | "schema-client": "2.2.1" 24 | }, 25 | "devDependencies": { 26 | "chai": "3.5.0", 27 | "chai-as-promised": "5.3.0", 28 | "chai-datetime": "1.4.1", 29 | "doctoc": "1.2.0", 30 | "eslint": "3.4.0", 31 | "eslint-plugin-import": "1.13.0", 32 | "mocha": "3.0.2", 33 | "nodemon": "1.10.2", 34 | "sinon": "1.17.5", 35 | "sinon-as-promised": "4.0.2" 36 | }, 37 | "eslintConfig": { 38 | "globals": { 39 | "__DEV__": true, 40 | "debug": true 41 | }, 42 | "env": { 43 | "node": true, 44 | "es6": true 45 | }, 46 | "rules": { 47 | "no-param-reassign": 0, 48 | "no-underscore-dangle": 0, 49 | "no-confusing-arrow": 0, 50 | "no-unused-vars": 1 51 | }, 52 | "parserOptions": { 53 | "sourceType": "module" 54 | } 55 | }, 56 | "scripts": { 57 | "watch": "nodemon --harmony server.js", 58 | "start": "node --harmony --optimize_for_size --max_old_space_size=460 --gc_interval=100 server.js", 59 | "test": "mocha --require test/index.js --recursive test/api --timeout 1000 --slow 200", 60 | "lint": "npm run eslint", 61 | "eslint": "npm run eslint:root && npm run eslint:api", 62 | "eslint:root": "eslint server.js env.js", 63 | "eslint:api": "eslint --ext .js --ignore-path .gitignore api", 64 | "docs": "doctoc .", 65 | "sloc": "sloc -e \"(node_modules|build|assets|\\.html$|\\.xml$|\\.svg$)\" -k total,source,comment -f cli-table ." 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the main Express app. 3 | */ 4 | 5 | // Dependencies 6 | const http = require('http'); 7 | const express = require('express'); 8 | 9 | // Express Middleware 10 | const cors = require('cors'); 11 | const morgan = require('morgan'); 12 | const bodyParser = require('body-parser'); 13 | const compression = require('compression'); 14 | const expressEnforcesSSL = require('express-enforces-ssl'); 15 | 16 | // Read `.env` into `process.env` 17 | require('dotenv').config({ 18 | silent: true, 19 | }); 20 | 21 | // Load nconf environment variable defaults 22 | require('nconf').env().defaults({ 23 | NODE_ENV: 'development', 24 | }); 25 | 26 | // Load environment 27 | const env = require('./env'); 28 | 29 | // Create Express App 30 | const main = express(); 31 | 32 | // HTTPS Forwarding 33 | // Needed for Heroku / Load Balancers 34 | main.enable('trust proxy'); 35 | 36 | // Force Redirect to SSL 37 | if (env.FORCE_SSL) { 38 | main.use(expressEnforcesSSL()); 39 | } 40 | 41 | // Enable CORS 42 | main.use(cors({ 43 | maxAge: 86400000, // 1 day 44 | })); 45 | 46 | // Gzip compression 47 | main.use(compression()); 48 | 49 | // Body parsing 50 | main.use(bodyParser.json()); 51 | main.use(bodyParser.urlencoded({ extended: true })); 52 | 53 | // Get session ID from header 54 | main.use((req, res, next) => { 55 | req.sessionID = req.get('x-session') || null; 56 | next(); 57 | }); 58 | 59 | // Logging 60 | main.use(morgan('short')); 61 | 62 | // Use API routes 63 | main.use(require('./api')(env)); 64 | 65 | // Start the HTTP server 66 | const server = http.createServer(main); 67 | server.listen(env.PORT, () => { 68 | // eslint-disable-next-line no-console 69 | console.log(`\nExpress Server [PORT: ${env.PORT}] [NODE_ENV: ${env.NODE_ENV}]\n`); 70 | }); 71 | -------------------------------------------------------------------------------- /test/api/v1/account.spec.js: -------------------------------------------------------------------------------- 1 | const schema = Test.schemaClient(); 2 | const api = Test.apiClient(schema); 3 | 4 | describe('/v1/account', () => { 5 | schema.init(); 6 | 7 | beforeEach(() => { 8 | schema.expectLoggedOut(); 9 | }); 10 | 11 | describe('GET /v1/account', () => { 12 | describe('when logged in', () => { 13 | beforeEach(() => { 14 | schema.reset(); 15 | schema.expectLoggedIn(); 16 | }); 17 | 18 | it('returns account data', () => { 19 | schema.expects([ 20 | { 21 | method: 'get', 22 | url: '/accounts/{id}', 23 | result: { 24 | id: 123, 25 | first_name: 'foo', 26 | } 27 | } 28 | ]); 29 | return api.get('/v1/account', { 30 | first_name: 'foo', 31 | last_name: 'bar', 32 | }).then(result => { 33 | assert.deepEqual(result, { 34 | id: 123, 35 | first_name: 'foo', 36 | }); 37 | }); 38 | }); 39 | 40 | it('throws an error when updating restricted fields', () => { 41 | return api.put('/v1/account', { 42 | bad_field: 'foo', 43 | }).then(result => { 44 | assert.ok(result && result.error); 45 | assert.equal(result.error, 'Error: Query may not specify `bad_field` on this resource'); 46 | }); 47 | }); 48 | }); 49 | }); 50 | 51 | describe('PUT /v1/account', () => { 52 | it('throws an error when logged out', () => { 53 | return api.put('/v1/account').then(result => { 54 | assert.ok(result && result.error); 55 | }); 56 | }); 57 | 58 | describe('when logged in', () => { 59 | beforeEach(() => { 60 | schema.reset(); 61 | schema.expectLoggedIn(); 62 | }); 63 | 64 | it('sets account fields which are allowed', () => { 65 | schema.expects([ 66 | { 67 | method: 'put', 68 | url: '/accounts/{id}', 69 | result: { 70 | first_name: 'foo', 71 | last_name: 'bar', 72 | } 73 | } 74 | ]); 75 | return api.put('/v1/account', { 76 | first_name: 'foo', 77 | last_name: 'bar', 78 | }).then(result => { 79 | assert.ok(result && result.first_name); 80 | }); 81 | }); 82 | 83 | it('throws an error when updating restricted fields', () => { 84 | return api.put('/v1/account', { 85 | bad_field: 'foo', 86 | }).then(result => { 87 | assert.ok(result && result.error); 88 | assert.equal(result.error, 'Error: Query may not specify `bad_field` on this resource'); 89 | }); 90 | }); 91 | }); 92 | }); 93 | 94 | describe('POST /v1/account', () => { 95 | it('throws an error when logged in', () => { 96 | schema.reset(); 97 | schema.expectLoggedIn(); 98 | return api.post('/v1/account').then(result => { 99 | assert.ok(result && result.error); 100 | }); 101 | }); 102 | 103 | it('creates account and logs in', () => { 104 | schema.expects([ 105 | { 106 | method: 'post', 107 | url: '/accounts', 108 | result: { 109 | id: 123, 110 | email: 'new@example.com', 111 | } 112 | }, 113 | { 114 | method: 'put', 115 | url: '/:sessions/{id}', 116 | result: { 117 | id: schema.sessionId, 118 | account_id: 123, 119 | } 120 | } 121 | ]); 122 | return api.post('/v1/account', { 123 | email: 'new@example.com', 124 | }).then(result => { 125 | assert.deepEqual(result, { 126 | id: 123, 127 | email: 'new@example.com', 128 | }); 129 | }); 130 | }); 131 | 132 | it('creates account with default customer group and type', () => { 133 | schema.expects([ 134 | { 135 | method: 'post', 136 | url: '/accounts', 137 | data: { 138 | email: 'new@example.com', 139 | group: 'customers', 140 | type: 'individual', 141 | }, 142 | result: { 143 | id: 123, 144 | email: 'new@example.com', 145 | group: 'customers', 146 | type: 'individual', 147 | } 148 | } 149 | ]); 150 | return api.post('/v1/account', { 151 | email: 'new@example.com', 152 | }).then(result => { 153 | assert.deepEqual(result, { 154 | id: 123, 155 | email: 'new@example.com', 156 | group: 'customers', 157 | type: 'individual', 158 | }); 159 | }); 160 | }); 161 | }); 162 | 163 | describe('POST /v1/account/login', () => { 164 | it('saves to session when successful', () => { 165 | schema.expects([ 166 | { 167 | method: 'get', 168 | url: '/accounts/:login', 169 | data: { 170 | email: 'customer@example.com', 171 | password: 'foobar' 172 | }, 173 | result: { 174 | id: 123, 175 | email: 'customer@example.com' 176 | } 177 | }, 178 | { 179 | method: 'put', 180 | url: '/:sessions/{id}', 181 | data: { 182 | id: schema.sessionId, 183 | account_id: 123, 184 | account_group: undefined, 185 | email: 'customer@example.com', 186 | } 187 | } 188 | ]); 189 | return api.post('/v1/account/login', { 190 | email: 'customer@example.com', 191 | password: 'foobar', 192 | }).then(result => { 193 | assert.deepEqual(result, { 194 | id: 123, 195 | email: 'customer@example.com' 196 | }); 197 | }); 198 | }); 199 | 200 | it('returns null when unsuccessful', () => { 201 | schema.expects([ 202 | { 203 | method: 'get', 204 | url: '/accounts/:login', 205 | result: null, 206 | } 207 | ]); 208 | return api.post('/v1/account/login', { 209 | email: 'customer@example.com', 210 | password: 'bad', 211 | }).then(result => { 212 | assert.deepEqual(result, null); 213 | }); 214 | }); 215 | }); 216 | 217 | describe('POST /v1/account/logout', () => { 218 | it('nullifies account id and group in session', () => { 219 | schema.expects([ 220 | { 221 | method: 'put', 222 | url: '/:sessions/{id}', 223 | data: { 224 | id: schema.sessionId, 225 | account_id: null, 226 | account_group: null, 227 | email: null, 228 | } 229 | } 230 | ]); 231 | return api.post('/v1/account/logout').then(result => { 232 | assert.deepEqual(result, { 233 | success: true 234 | }); 235 | }); 236 | }); 237 | }); 238 | 239 | describe('POST /v1/account/recover', () => { 240 | it('sets recovery params and sends notification', () => { 241 | const expireDate = new Date(); 242 | schema.expects([ 243 | { 244 | method: 'get', 245 | url: '/accounts/{email}', 246 | result: { 247 | id: 123 248 | } 249 | }, 250 | { 251 | method: 'put', 252 | url: '/accounts/{id}', 253 | data: (data) => { 254 | return { 255 | id: 123, 256 | $notify: 'password-reset', 257 | password_reset_url: 'http://example.com/account/recover/' + data.password_reset_key, 258 | password_reset_key: data.password_reset_key, 259 | password_reset_expired: data.password_reset_expired 260 | }; 261 | } 262 | } 263 | ]); 264 | return api.post('/v1/account/recover', { 265 | email: 'customer@example.com', 266 | reset_url: 'http://example.com/account/recover/{key}', 267 | }).then(result => { 268 | assert.strictEqual(result.success, true); 269 | }); 270 | }); 271 | 272 | it('returns an error if email is blank', () => { 273 | return api.post('/v1/account/recover', { 274 | email: '', 275 | }).then(result => { 276 | assert.ok(result); 277 | assert.ok(result.errors.email); 278 | assert.strictEqual(result.errors.email.code, 'REQUIRED'); 279 | }); 280 | }); 281 | 282 | it('resets password with valid key, before expired', () => { 283 | schema.expects([ 284 | { 285 | method: 'get', 286 | url: '/accounts/:first', 287 | data: { 288 | password_reset_key: 'test_key' 289 | }, 290 | result: { 291 | id: 123, 292 | password_reset_expired: Date.now() + 999999, 293 | } 294 | }, 295 | { 296 | method: 'put', 297 | url: '/accounts/{id}', 298 | data: { 299 | id: 123, 300 | password: 'new password', 301 | password_reset_url: null, 302 | password_reset_key: null, 303 | password_reset_expired: null 304 | }, 305 | result: { 306 | id: 123 307 | } 308 | } 309 | ]); 310 | return api.post('/v1/account/recover', { 311 | reset_key: 'test_key', 312 | password: 'new password' 313 | }).then(result => { 314 | assert.strictEqual(result.success, true); 315 | }); 316 | }); 317 | 318 | it('returns an error if reset is expired', () => { 319 | schema.expects([ 320 | { 321 | method: 'get', 322 | url: '/accounts/:first', 323 | data: { 324 | password_reset_key: 'test_key', 325 | }, 326 | result: { 327 | id: 123, 328 | password_reset_expired: Date.now() - 1, 329 | }, 330 | }, 331 | ]); 332 | return api.post('/v1/account/recover', { 333 | reset_key: 'test_key', 334 | password: 'new password' 335 | }).then(result => { 336 | assert.ok(result.errors); 337 | assert.ok(result.errors.reset_key); 338 | assert.strictEqual(result.errors.reset_key.code, 'INVALID'); 339 | }); 340 | }); 341 | }); 342 | 343 | describe('GET /v1/account/orders', () => { 344 | it('throws an error when logged out', () => { 345 | return api.get('/v1/account/orders').then(result => { 346 | assert.ok(result && result.error); 347 | }); 348 | }); 349 | 350 | describe('when logged in', () => { 351 | beforeEach(() => { 352 | schema.reset(); 353 | schema.expectLoggedIn(); 354 | }); 355 | 356 | it('returns account orders', () => { 357 | schema.expects([ 358 | { 359 | method: 'get', 360 | url: '/orders', 361 | data: { 362 | account_id: schema.sessionId, 363 | fields: undefined, 364 | limit: undefined, 365 | page: undefined, 366 | sort: undefined, 367 | expand: 'items.product, items.variant, items.bundle_items.product, items.bundle_items.variant', 368 | }, 369 | result: { 370 | count: 0, 371 | results: [], 372 | }, 373 | }, 374 | ]); 375 | return api.get('/v1/account/orders').then(result => { 376 | assert.deepEqual(result, { 377 | count: 0, 378 | results: [], 379 | }); 380 | }); 381 | }); 382 | }); 383 | }); 384 | 385 | describe('GET /v1/account/orders/:id', () => { 386 | it('throws an error when logged out', () => { 387 | return api.get('/v1/account/orders/1').then(result => { 388 | assert.ok(result && result.error); 389 | }); 390 | }); 391 | 392 | describe('when logged in', () => { 393 | beforeEach(() => { 394 | schema.reset(); 395 | schema.expectLoggedIn(); 396 | }); 397 | 398 | it('returns account orders', () => { 399 | schema.expects([ 400 | { 401 | method: 'get', 402 | url: '/orders/{id}', 403 | data: { 404 | id: '1', 405 | account_id: schema.sessionId, 406 | fields: undefined, 407 | expand: 'items.product, items.variant, items.bundle_items.product, items.bundle_items.variant', 408 | }, 409 | result: { 410 | id: '1', 411 | }, 412 | }, 413 | ]); 414 | return api.get('/v1/account/orders/1').then(result => { 415 | assert.deepEqual(result, { 416 | id: '1', 417 | }); 418 | }); 419 | }); 420 | }); 421 | }); 422 | }); 423 | -------------------------------------------------------------------------------- /test/api/v1/cart.spec.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const schema = Test.schemaClient(); 3 | const api = Test.apiClient(schema); 4 | 5 | schema.expectCart = (props) => { 6 | schema.expects([ 7 | { 8 | method: 'get', 9 | url: '/carts/{cart_id}', 10 | data: { 11 | cart_id: 123, 12 | }, 13 | result: _.merge({ id: 123 }, props), 14 | }, 15 | ]); 16 | return schema; 17 | }; 18 | 19 | schema.expectCartResult = (props) => { 20 | schema.expects([ 21 | { 22 | method: 'get', 23 | url: '/carts/{id}', 24 | result: { 25 | id: 123, 26 | items: [ item ], 27 | }, 28 | }, 29 | ]); 30 | return schema; 31 | }; 32 | 33 | describe('/v1/cart', () => { 34 | schema.init(); 35 | 36 | beforeEach(() => { 37 | schema.expectSession({ 38 | cart_id: 123, 39 | }); 40 | }); 41 | 42 | describe('GET /v1/cart', () => { 43 | it('returns cart from session', () => { 44 | schema.expects([ 45 | { 46 | method: 'get', 47 | url: '/carts/{id}', 48 | result: { 49 | id: 123, 50 | }, 51 | }, 52 | ]); 53 | return api.get('/v1/cart').then(result => { 54 | assert.deepEqual(result, { 55 | id: 123 56 | }); 57 | }); 58 | }); 59 | 60 | it('returns null when cart is not created first', () => { 61 | schema.reset().expectSession().expects([ 62 | { 63 | method: 'get', 64 | url: '/carts/{id}', 65 | result: null, 66 | } 67 | ]); 68 | return api.get('/v1/cart').then(result => { 69 | assert.isNull(result); 70 | }); 71 | }); 72 | }); 73 | 74 | describe('PUT /v1/cart', () => { 75 | it('creates new cart if not existing', () => { 76 | schema.reset().expectSession().expects([ 77 | { 78 | method: 'post', 79 | url: '/carts', 80 | result: { 81 | id: 123, 82 | }, 83 | }, 84 | { 85 | method: 'put', 86 | url: '/:sessions/{id}', 87 | data: { 88 | id: schema.sessionId, 89 | cart_id: 123, 90 | }, 91 | }, 92 | { 93 | method: 'put', 94 | url: '/carts/{cart_id}', 95 | data: { 96 | cart_id: 123, 97 | $promotions: true, 98 | shipping: { name: 'Test Customer' }, 99 | }, 100 | result: { 101 | id: 123, 102 | shipping: { name: 'Test Customer' }, 103 | }, 104 | }, 105 | { 106 | method: 'get', 107 | url: '/carts/{id}', 108 | result: { 109 | id: 123, 110 | shipping: { name: 'Test Customer' }, 111 | }, 112 | }, 113 | ]); 114 | return api.put('/v1/cart', { 115 | shipping: { name: 'Test Customer' }, 116 | }).then(result => { 117 | assert.deepEqual(result, { 118 | id: 123, 119 | shipping: { name: 'Test Customer' }, 120 | }); 121 | }); 122 | }); 123 | 124 | it('updates cart with items', () => { 125 | schema.expectCart(); 126 | schema.expects([ 127 | { 128 | method: 'put', 129 | url: '/carts/{cart_id}', 130 | data: { 131 | cart_id: 123, 132 | $promotions: true, 133 | items: [{ 134 | product_id: 1, 135 | quantity: 2, 136 | }], 137 | }, 138 | result: { 139 | id: 123, 140 | items: [{ 141 | product_id: 1, 142 | quantity: 2, 143 | }], 144 | }, 145 | }, 146 | { 147 | method: 'get', 148 | url: '/carts/{id}', 149 | result: { 150 | id: 123, 151 | items: [{ 152 | product_id: 1, 153 | quantity: 2, 154 | }], 155 | }, 156 | }, 157 | ]); 158 | return api.put('/v1/cart', { 159 | items: [{ 160 | product_id: 1, 161 | quantity: 2, 162 | }], 163 | }).then(result => { 164 | assert.deepEqual(result, { 165 | id: 123, 166 | items: [{ 167 | product_id: 1, 168 | quantity: 2, 169 | }], 170 | }); 171 | }); 172 | }); 173 | }); 174 | 175 | describe('POST /v1/cart', () => { 176 | it('creates new cart if not existing', () => { 177 | schema.reset().expectSession().expects([ 178 | { 179 | method: 'post', 180 | url: '/carts', 181 | result: { 182 | id: 123, 183 | } 184 | }, 185 | { 186 | method: 'put', 187 | url: '/:sessions/{id}', 188 | data: { 189 | id: schema.sessionId, 190 | cart_id: 123 191 | }, 192 | }, 193 | { 194 | method: 'put', 195 | url: '/carts/{cart_id}', 196 | data: { 197 | cart_id: 123, 198 | $promotions: true, 199 | shipping: { name: 'Test Customer' }, 200 | }, 201 | result: { 202 | id: 123, 203 | shipping: { name: 'Test Customer' }, 204 | }, 205 | }, 206 | { 207 | method: 'get', 208 | url: '/carts/{id}', 209 | result: { 210 | id: 123, 211 | shipping: { name: 'Test Customer' }, 212 | }, 213 | }, 214 | ]); 215 | return api.post('/v1/cart', { 216 | shipping: { name: 'Test Customer' }, 217 | }).then(result => { 218 | assert.deepEqual(result, { 219 | id: 123, 220 | shipping: { name: 'Test Customer' }, 221 | }); 222 | }); 223 | }); 224 | 225 | it('updates cart with items', () => { 226 | schema.expectCart().expects([ 227 | { 228 | method: 'put', 229 | url: '/carts/{cart_id}', 230 | data: { 231 | cart_id: 123, 232 | $promotions: true, 233 | items: [{ 234 | product_id: 1, 235 | quantity: 2, 236 | }], 237 | }, 238 | result: { 239 | id: 123, 240 | items: [{ 241 | product_id: 1, 242 | quantity: 2, 243 | }], 244 | }, 245 | }, 246 | { 247 | method: 'get', 248 | url: '/carts/{id}', 249 | result: { 250 | id: 123, 251 | items: [{ 252 | product_id: 1, 253 | quantity: 2, 254 | }], 255 | }, 256 | }, 257 | ]); 258 | return api.post('/v1/cart', { 259 | items: [{ 260 | product_id: 1, 261 | quantity: 2, 262 | }], 263 | }).then(result => { 264 | assert.deepEqual(result, { 265 | id: 123, 266 | items: [{ 267 | product_id: 1, 268 | quantity: 2, 269 | }], 270 | }); 271 | }); 272 | }); 273 | }); 274 | 275 | describe('POST /v1/cart/add-item', () => { 276 | schema.expectCartAddItem = (itemProps = {}) => { 277 | const item = _.merge( 278 | { 279 | product_id: 1, 280 | quantity: 2, 281 | }, 282 | itemProps 283 | ); 284 | schema.expectCart().expects([ 285 | { 286 | method: 'get', 287 | url: '/products/{product_id}', 288 | result: { 289 | id: 1, 290 | }, 291 | }, 292 | { 293 | method: 'post', 294 | url: '/carts/{cart_id}/items', 295 | data: _.merge({}, item, { cart_id: 123 }), 296 | }, 297 | { 298 | method: 'get', 299 | url: '/carts/{id}', 300 | result: { 301 | id: 123, 302 | items: [ item ], 303 | }, 304 | }, 305 | ]); 306 | return schema; 307 | }; 308 | 309 | schema.expectCartAddItemWithVariant = (itemProps = {}) => { 310 | const item = _.merge( 311 | { 312 | product_id: 1, 313 | variant_id: 2, 314 | quantity: 2, 315 | }, 316 | itemProps 317 | ); 318 | schema.expectCart().expects([ 319 | { 320 | method: 'get', 321 | url: '/products/{product_id}', 322 | data: { 323 | product_id: 1, 324 | active: true, 325 | include: { 326 | variant: { 327 | url: '/products:variants/2', 328 | data: { 329 | parent_id: 1, 330 | active: true 331 | } 332 | } 333 | } 334 | }, 335 | result: { 336 | id: 1, 337 | variable: true, 338 | variant: { 339 | id: 2, 340 | } 341 | }, 342 | }, 343 | { 344 | method: 'post', 345 | url: '/carts/{cart_id}/items', 346 | data: _.merge({}, item, { cart_id: 123 }), 347 | }, 348 | { 349 | method: 'get', 350 | url: '/carts/{id}', 351 | result: { 352 | id: 123, 353 | items: [ item ], 354 | }, 355 | }, 356 | ]); 357 | return schema; 358 | }; 359 | 360 | it('adds an item to cart', () => { 361 | schema.expectCartAddItem(); 362 | api.post('/v1/cart/add-item', { 363 | product_id: 1, 364 | quantity: 2, 365 | }).then(result => { 366 | assert.deepEqual(result, { 367 | id: 123, 368 | items: [{ 369 | product_id: 1, 370 | quantity: 2, 371 | }], 372 | }); 373 | }); 374 | }); 375 | 376 | it('adds an item with variant to cart', () => { 377 | schema.expectCartAddItemWithVariant(); 378 | api.post('/v1/cart/add-item', { 379 | product_id: 1, 380 | variant_id: 2, 381 | quantity: 2, 382 | }).then(result => { 383 | assert.deepEqual(result, { 384 | id: 123, 385 | items: [{ 386 | product_id: 1, 387 | variant_id: 2, 388 | quantity: 2, 389 | }], 390 | }); 391 | }); 392 | }); 393 | 394 | it('throws an error when specifying price', () => { 395 | schema.expectCart().expects([ 396 | { 397 | method: 'get', 398 | url: '/products/{product_id}', 399 | result: { 400 | id: 1, 401 | }, 402 | }, 403 | ]); 404 | api.post('/v1/cart/add-item', { 405 | product_id: 1, 406 | quantity: 2, 407 | price: 10, 408 | }).then(result => { 409 | assert.ok(result && result.error); 410 | }); 411 | }); 412 | 413 | it('throws an error when missing product_id', () => { 414 | api.post('/v1/cart/add-item', { 415 | product_id: undefined, 416 | }).then(result => { 417 | assert.ok(result && result.error); 418 | }); 419 | }); 420 | }); 421 | 422 | describe('POST /v1/cart/remove-item', () => { 423 | it('removes an item from cart', () => { 424 | // TODO 425 | schema.reset(); 426 | }); 427 | }); 428 | 429 | describe('POST /v1/cart/shipment-rating', () => { 430 | it('generates shipment rates from cart', () => { 431 | // TODO 432 | schema.reset(); 433 | }); 434 | }); 435 | 436 | describe('POST /v1/cart/checkout', () => { 437 | it('converts cart to order', () => { 438 | // TODO 439 | schema.reset(); 440 | }); 441 | }); 442 | }); 443 | -------------------------------------------------------------------------------- /test/api/v1/categories.spec.js: -------------------------------------------------------------------------------- 1 | const schema = Test.schemaClient(); 2 | const api = Test.apiClient(schema); 3 | 4 | describe('/v1/categories', () => { 5 | schema.init(); 6 | 7 | describe('GET /v1/categories/:id', () => { 8 | it('returns category by id', () => { 9 | // TODO 10 | }); 11 | }); 12 | 13 | describe('GET /v1/categories/:id/children', () => { 14 | it('returns category children by id', () => { 15 | // TODO 16 | }); 17 | }); 18 | 19 | describe('GET /v1/categories/:id/products', () => { 20 | it('returns category products by id', () => { 21 | // TODO 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/api/v1/contacts.spec.js: -------------------------------------------------------------------------------- 1 | const schema = Test.schemaClient(); 2 | const api = Test.apiClient(schema); 3 | 4 | describe('/v1/contacts', () => { 5 | schema.init(); 6 | 7 | describe('GET /v1/contacts/subscribe', () => { 8 | it('subscribes contact to email lists', () => { 9 | // TODO 10 | }); 11 | }); 12 | 13 | describe('GET /v1/contacts/unsubscribe', () => { 14 | it('unsubscribes contact from email lists', () => { 15 | // TODO 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/api/v1/pages.spec.js: -------------------------------------------------------------------------------- 1 | const schema = Test.schemaClient(); 2 | const api = Test.apiClient(schema); 3 | 4 | describe('/v1/pages', () => { 5 | schema.init(); 6 | 7 | describe('GET /v1/pages/:id', () => { 8 | it('returns page by id', () => { 9 | // TODO 10 | }); 11 | }); 12 | 13 | describe('GET /v1/pages/:id/articles', () => { 14 | it('returns page articles by id', () => { 15 | // TODO 16 | }); 17 | }); 18 | 19 | describe('GET /v1/pages/:id/articles/:article_id', () => { 20 | it('returns single page article by id', () => { 21 | // TODO 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/api/v1/products.spec.js: -------------------------------------------------------------------------------- 1 | const schema = Test.schemaClient(); 2 | const api = Test.apiClient(schema); 3 | 4 | describe('/v1/products', () => { 5 | schema.init(); 6 | 7 | describe('GET /v1/products?category=:id', () => { 8 | it('returns active products by category', () => { 9 | // TODO 10 | }); 11 | }); 12 | 13 | describe('GET /v1/products/:id', () => { 14 | it('returns a single active product', () => { 15 | // TODO 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/api/v1/session.spec.js: -------------------------------------------------------------------------------- 1 | const schema = Test.schemaClient(); 2 | const api = Test.apiClient(schema); 3 | 4 | describe('/v1/session', () => { 5 | schema.init(); 6 | 7 | describe('GET /v1/session', () => { 8 | it('returns current session', () => { 9 | // TODO 10 | }); 11 | }); 12 | 13 | describe('PUT /v1/session', () => { 14 | it('updates current session', () => { 15 | // TODO 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/api/v1/slug.spec.js: -------------------------------------------------------------------------------- 1 | const schema = Test.schemaClient(); 2 | const api = Test.apiClient(schema); 3 | 4 | describe('/v1/slug', () => { 5 | schema.init(); 6 | 7 | describe('GET /v1/slug/:id*', () => { 8 | it('returns record by slug', () => { 9 | // TODO 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const util = require('util'); 3 | const express = require('express'); 4 | const Promise = require('bluebird'); 5 | const Schema = require('schema-client'); 6 | const API = require('../api'); 7 | 8 | // Read `.env` into `process.env` 9 | require('dotenv').config({ 10 | silent: true, 11 | }); 12 | 13 | // Load nconf environment variable defaults 14 | require('nconf').env().defaults({ 15 | NODE_ENV: 'development', 16 | }); 17 | 18 | // Load env 19 | const env = require('../env'); 20 | 21 | // Global assert 22 | global.assert = require('chai').assert; 23 | 24 | // Global test utils 25 | const Test = global.Test = module.exports; 26 | 27 | // Helper to format values for test output 28 | const inspect = (value) => { 29 | return util.inspect(value, { 30 | showHidden: true, 31 | depth: 7, 32 | }); 33 | }; 34 | 35 | // Helper to create a test schema client 36 | Test.schemaClient = (sessionId) => { 37 | const schema = new Schema.Client(); 38 | 39 | // Default session ID 40 | sessionId = schema.sessionId = sessionId || 'session'; 41 | 42 | let expectations = []; 43 | let inited = false; 44 | 45 | // Get/Set expectations 46 | schema.expects = (requests) => { 47 | if (!inited) { 48 | assert.fail('Schema test client must call init() in the top level describe() first'); 49 | } 50 | if (requests instanceof Array) { 51 | requests.forEach(req => expectations.push(req)); 52 | } else if (requests && typeof requests === 'object') { 53 | expectations.push(requests); 54 | } 55 | return expectations; 56 | }; 57 | 58 | // Init hooks 59 | schema.init = () => { 60 | inited = true; 61 | beforeEach(() => { 62 | schema.reset(); 63 | }); 64 | afterEach(() => { 65 | expectations.forEach(exp => { 66 | const expRequest = inspect(exp); 67 | assert.fail(undefined, exp, 'Schema expected:\n' + expRequest + '\nbut received nothing'); 68 | }); 69 | schema.reset(); 70 | }); 71 | }; 72 | 73 | // Reset expectations 74 | schema.reset = () => { 75 | expectations = []; 76 | return schema; 77 | }; 78 | 79 | // Set session expectation 80 | schema.expectSession = (props) => { 81 | schema.expects([ 82 | { 83 | method: 'get', 84 | url: '/:sessions/{id}', 85 | result: _.merge({ id: sessionId }, props), 86 | }, 87 | { 88 | method: 'get', 89 | url: '/:clients/:self', 90 | result: { 91 | id: 'test', 92 | }, 93 | }, 94 | { 95 | method: 'get', 96 | url: '/:currencies', 97 | }, 98 | ]); 99 | return schema; 100 | }; 101 | 102 | // Set logged out expectation 103 | schema.expectLoggedOut = () => { 104 | schema.expects([ 105 | { 106 | method: 'get', 107 | url: '/:sessions/{id}', 108 | result: { 109 | id: sessionId, 110 | account_id: null, 111 | }, 112 | }, 113 | { 114 | method: 'get', 115 | url: '/:clients/:self', 116 | result: { 117 | id: 'test', 118 | }, 119 | }, 120 | { 121 | method: 'get', 122 | url: '/:currencies', 123 | }, 124 | ]); 125 | return schema; 126 | }; 127 | 128 | // Set logged out expectation 129 | schema.expectLoggedIn = () => { 130 | schema.expects([ 131 | { 132 | method: 'get', 133 | url: '/:sessions/{id}', 134 | result: { 135 | id: sessionId, 136 | account_id: sessionId, 137 | }, 138 | }, 139 | { 140 | method: 'get', 141 | url: '/:clients/:self', 142 | result: { 143 | id: 'test', 144 | }, 145 | }, 146 | { 147 | method: 'get', 148 | url: '/:currencies', 149 | }, 150 | ]); 151 | return schema; 152 | }; 153 | 154 | const requestHandler = (method, url, data, callback) => { 155 | const actualRequest = { 156 | method: method, 157 | url: url, 158 | data: data 159 | }; 160 | let result = undefined; 161 | const expectedRequest = expectations.shift(); 162 | if (expectedRequest) { 163 | result = expectedRequest.result; 164 | if (result !== undefined) { 165 | delete expectedRequest.result; 166 | } 167 | if (expectedRequest.data === undefined) { 168 | delete actualRequest.data; 169 | } else if (typeof expectedRequest.data === 'function') { 170 | expectedRequest.data = expectedRequest.data(data); 171 | } 172 | try { 173 | assert.deepEqual(expectedRequest, actualRequest); 174 | } catch (err) { 175 | const expected = inspect(expectedRequest); 176 | const actual = inspect(actualRequest); 177 | return Promise.reject('Schema expected: \n' + expected + '\nbut received: \n' + actual); 178 | } 179 | } 180 | if (callback) { 181 | callback(result); 182 | } 183 | return Promise.resolve(result); 184 | } 185 | 186 | _.each(['get', 'put', 'post', 'delete'], (method) => { 187 | schema[method] = requestHandler.bind(schema, method); 188 | }); 189 | 190 | return schema; 191 | }; 192 | 193 | // Helper to create a test API client 194 | Test.apiClient = (schema) => { 195 | const router = API(env, schema); 196 | 197 | const requestHandler = (method, url, data) => { 198 | return new Promise(resolve => { 199 | let req = Test.buildReq(method, url, data, schema.sessionId); 200 | let res = Test.buildRes(req, resolve); 201 | router.handle(req, res, (err) => { 202 | if (err) { 203 | return res.status(500).json({ 204 | error: 'Router Error: ' + err.toString() 205 | }); 206 | } 207 | return res.status(404).json({ 208 | error: 'Route not found ' + method.toUpperCase() + ' ' + url 209 | }); 210 | }); 211 | }); 212 | }; 213 | 214 | let api = {}; 215 | _.each(['get', 'put', 'post', 'delete'], (method) => { 216 | api[method] = requestHandler.bind(api, method); 217 | }); 218 | 219 | return api; 220 | }; 221 | 222 | // Helper to build express request 223 | Test.buildReq = (method, url, data, sessionId) => { 224 | let req = new express.request.__proto__.constructor(); 225 | 226 | req.headers = {}; 227 | req.rawHeaders = []; 228 | req.method = method; 229 | req.url = url 230 | req.hostname = 'api'; 231 | req.data = data; 232 | req.body = data; 233 | req.session = { id: sessionId }; 234 | req.sessionId = sessionId; 235 | req.sessionID = sessionId; 236 | 237 | return req; 238 | }; 239 | 240 | // Helper to build express response 241 | Test.buildRes = (req, resolve) => { 242 | let res = new express.response.__proto__.constructor(req); 243 | 244 | res.end = (result) => { 245 | if (res.sent) { 246 | return res; 247 | } 248 | res.sent = true; 249 | resolve(result); 250 | return res; 251 | }; 252 | 253 | res.status = (code) => { 254 | res.statusCode = ~~code; 255 | return res; 256 | }; 257 | 258 | res.send = res.end; 259 | res.json = res.end; 260 | 261 | res.append = undefined; 262 | res.render = undefined; 263 | res.redirect = undefined; 264 | res.links = undefined; 265 | res.jsonp = undefined; 266 | res.download = undefined; 267 | res.cookie = undefined; 268 | res.clearCookie = undefined; 269 | res.sendFile = undefined; 270 | 271 | return res; 272 | }; 273 | 274 | // Require modules from base path 275 | Test.requireBase = (path) => { 276 | return require('../' + path) 277 | }; 278 | --------------------------------------------------------------------------------