From ec0680069dca68c480ad4cf3368081c411b25910 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Fri, 14 Sep 2018 14:43:51 -0700 Subject: [PATCH] feat: add an api to add items to a cart atomically Signed-off-by: Raymond Feng --- package-lock.json | 118 ++++++++++-------- package.json | 12 +- src/controllers/shopping-cart.controller.ts | 25 +++- src/models/shopping-cart.model.ts | 8 +- src/repositories/shopping-cart.repository.ts | 57 ++++++++- .../shopping-cart.controller.acceptance.ts | 35 +++++- .../shopping-cart.repository.integration.ts | 29 +++-- 7 files changed, 210 insertions(+), 74 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9fa3da699..197468945 100644 --- a/package-lock.json +++ b/package-lock.json @@ -330,15 +330,15 @@ } }, "@loopback/boot": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/@loopback/boot/-/boot-0.13.2.tgz", - "integrity": "sha512-J0LkvsXQ2sLBtcHdqk7765ZZ+Cki5QeiaGjHTXIFp1tUGF6owj94wEW2KpCD33Ts8WECCb0PTE5ojajjuywgHw==", + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@loopback/boot/-/boot-0.13.5.tgz", + "integrity": "sha512-E5j788KV91QON+w73CgLbsyjwLh0xoeG6h81GHMTucoBXYrH+jplawNFucX6hytHLgA3KQslXp5ZP1ZZ0FNFmg==", "requires": { "@loopback/context": "^0.12.8", - "@loopback/core": "^0.11.8", + "@loopback/core": "^0.11.9", "@loopback/dist-util": "^0.3.7", - "@loopback/repository": "^0.16.2", - "@loopback/service-proxy": "^0.8.2", + "@loopback/repository": "^0.16.5", + "@loopback/service-proxy": "^0.8.3", "@types/debug": "0.0.30", "@types/glob": "^5.0.35", "debug": "^3.1.0", @@ -394,9 +394,9 @@ } }, "@loopback/core": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/@loopback/core/-/core-0.11.8.tgz", - "integrity": "sha512-RHnUCFsH3jY0Ty1AxMb1XyWj7+jS0di+GPOGUC6FKG/7yvft5F+DfiICRKS18CG0i1ky0HNHjTMSE7oIbQzdyA==", + "version": "0.11.9", + "resolved": "https://registry.npmjs.org/@loopback/core/-/core-0.11.9.tgz", + "integrity": "sha512-kaFXD0KQecHMpxvyxnDH2FngU5t0nOBjdNd+7AoeYk00tZZToYkNXKakP9kHuTYnp+T6RRBl+fW9f4QBE/bxRQ==", "requires": { "@loopback/context": "^0.12.8", "@loopback/dist-util": "^0.3.7" @@ -411,9 +411,9 @@ } }, "@loopback/http-server": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@loopback/http-server/-/http-server-0.3.8.tgz", - "integrity": "sha512-wf/GBxg82Xm3jaDa9dFgSKdk2bSN4z3lTObnRzpnN1Nzt2JyEIrzAjLzPtzYWTEUb59qUtqbhYi/pP/bsoWb6Q==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@loopback/http-server/-/http-server-0.3.9.tgz", + "integrity": "sha512-0pjLIsxjz8rCAYBSSLfhC6NLp0OJp0xsbAtdkZ0ljYzDcZTxDgkuTxVErVBVSRhvgJn3UK27wjG6E4OAPy0N9Q==", "requires": { "@loopback/dist-util": "^0.3.7", "p-event": "^2.0.0" @@ -430,14 +430,14 @@ } }, "@loopback/openapi-v3": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/@loopback/openapi-v3/-/openapi-v3-0.13.2.tgz", - "integrity": "sha512-i7wMhI+x+inD/3Ufiig04o0UxOv1jmoavrBxSumRbopdAFIwaDHWG3ZfN11Q3nNj24q3ah7KMLEjtT2vHvhjRA==", + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@loopback/openapi-v3/-/openapi-v3-0.14.2.tgz", + "integrity": "sha512-1NMV7yWj+faVLDROfcHqtDjIEUCmls8h8NbBPbcDLjw6+XsjGmRy+Jf1Ri4QSYAIbCw2I/g0IO1LD90/JfPk8A==", "requires": { "@loopback/context": "^0.12.8", "@loopback/dist-util": "^0.3.7", "@loopback/openapi-v3-types": "^0.9.2", - "@loopback/repository-json-schema": "^0.10.9", + "@loopback/repository-json-schema": "^0.10.12", "debug": "^3.1.0", "lodash": "^4.17.5" } @@ -452,38 +452,38 @@ } }, "@loopback/repository": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@loopback/repository/-/repository-0.16.2.tgz", - "integrity": "sha512-aYfboUsqCrapZr18IXNnpGp5M1MKPzVvJLMCJ56hdAA/4OWJ8wQH6eEL3aOWIgM7uaMvh0/ShHfXJVJzWw+Xfg==", + "version": "0.16.5", + "resolved": "https://registry.npmjs.org/@loopback/repository/-/repository-0.16.5.tgz", + "integrity": "sha512-rAKDibpEcHefOZMts13nhtOKbgD9+kZaZpW2H08JFcnpSNM+eEe4KE7T2c/vB1jufUWoP/f2Ij6RhRy+BOtQsw==", "requires": { "@loopback/context": "^0.12.8", - "@loopback/core": "^0.11.8", + "@loopback/core": "^0.11.9", "@loopback/dist-util": "^0.3.7", "lodash": "^4.17.10", "loopback-datasource-juggler": "^3.23.0" } }, "@loopback/repository-json-schema": { - "version": "0.10.9", - "resolved": "https://registry.npmjs.org/@loopback/repository-json-schema/-/repository-json-schema-0.10.9.tgz", - "integrity": "sha512-YTjQ8fLfrn61iXZfCt4o3llvLC+wh/KotMM4L1E7HCRgcHNg6LTp2+sXL8JXVuDD+GcpqBjfc6PwePxJ+w3dsQ==", + "version": "0.10.12", + "resolved": "https://registry.npmjs.org/@loopback/repository-json-schema/-/repository-json-schema-0.10.12.tgz", + "integrity": "sha512-OxjGlB4UM1nLTx/67jDuGvHYPQ8nBCLj/Ov3z2XsRr6VulU0ZwNeWYVDCpxj/dWkHNvtr4KWIoBS4Z/cL/vbXw==", "requires": { "@loopback/context": "^0.12.8", "@loopback/dist-util": "^0.3.7", "@loopback/metadata": "^0.9.8", - "@loopback/repository": "^0.16.2", + "@loopback/repository": "^0.16.5", "@types/json-schema": "^6.0.1" } }, "@loopback/rest": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@loopback/rest/-/rest-0.21.1.tgz", - "integrity": "sha512-xI+Gb5lAqsFRzouR+dNbAkJIt0iMP5v7xWqh/jbPp1Iig7vKsZ8gyQ0jJUkGMoYBVDJV831cVVhLoaGhfSqeXQ==", + "version": "0.22.2", + "resolved": "https://registry.npmjs.org/@loopback/rest/-/rest-0.22.2.tgz", + "integrity": "sha512-aVq9pumhrYooQcdTz+bjeaUGgNYoOTr8PxkUYsOx7NY639map1ALqnXkizlbHr153zta4lK6jJ9ZtaTVrR9neQ==", "requires": { "@loopback/context": "^0.12.8", - "@loopback/core": "^0.11.8", - "@loopback/http-server": "^0.3.8", - "@loopback/openapi-v3": "^0.13.2", + "@loopback/core": "^0.11.9", + "@loopback/http-server": "^0.3.9", + "@loopback/openapi-v3": "^0.14.2", "@loopback/openapi-v3-types": "^0.9.2", "@types/cors": "^2.8.3", "@types/express": "^4.11.1", @@ -508,12 +508,12 @@ } }, "@loopback/service-proxy": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@loopback/service-proxy/-/service-proxy-0.8.2.tgz", - "integrity": "sha512-wbkfKCTOJgoM+kmkcOTxn2LMmzQNLWxFJhLt/WtFyhSuw9furCtWyVfIe/fj+7Vn5urJFCHIx8ktU/Zk5uHypg==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@loopback/service-proxy/-/service-proxy-0.8.3.tgz", + "integrity": "sha512-HrGtTLGODfqznDZWMK/cydKKXzscVdwDxWHqcbVcVjeeZgQJCwuRck1lY0v4umztdu+GEZ14aVhjKsE2OpJKFw==", "requires": { "@loopback/context": "^0.12.8", - "@loopback/core": "^0.11.8", + "@loopback/core": "^0.11.9", "@loopback/dist-util": "^0.3.7", "loopback-datasource-juggler": "^3.23.0" } @@ -3159,15 +3159,15 @@ } }, "loopback-connector-mongodb": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/loopback-connector-mongodb/-/loopback-connector-mongodb-3.6.0.tgz", - "integrity": "sha512-DZyZmGh3SMArYToQgw3mU2u+aDV2A3BOXxVNFcjbN/tFgmc9Clafzpkel7cX9pIraMhlXlu1rqtMabnIlkBP5Q==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/loopback-connector-mongodb/-/loopback-connector-mongodb-3.7.1.tgz", + "integrity": "sha512-STbmIsrpnu1XKDsGBDeu4iUQqaQAa/VX0+boRNk7YVZ6KDJchxZxmw7zwfPxOZpz5UYgchZHTaJAlKgLGiziWQ==", "requires": { - "async": "^2.6.0", + "async": "^2.6.1", "bson": "^1.0.6", "debug": "^3.1.0", "loopback-connector": "^4.5.0", - "mongodb": "^3.0.1", + "mongodb": "^3.1.4", "strong-globalize": "^4.1.1" } }, @@ -3288,6 +3288,12 @@ "mimic-fn": "^1.0.0" } }, + "memory-pager": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.1.0.tgz", + "integrity": "sha512-Mf9OHV/Y7h6YWDxTzX/b4ZZ4oh9NSXblQL8dtPCOomOtZciEHxePR78+uHFLLlsk01A6jVHhHsQZZ/WcIPpnzg==", + "optional": true + }, "meow": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/meow/-/meow-5.0.0.tgz", @@ -3456,18 +3462,18 @@ } }, "mongodb": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.1.4.tgz", - "integrity": "sha512-BGUxo4a/p5KtZpOn6+z6iZXTHfDxKDvibHQap9uMJqQouwoszvTIO/QbVZkaSX3Spny0jtTEeHc0FwfpGbtEzA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.1.5.tgz", + "integrity": "sha512-ZZB9Fiw/MAqL9jvhRQmaVVISYOSaw/MqK2XfGyv7kkTGu/ri541UGX92izBKPuHM7vHCxVtz5lqCokLhaddY8g==", "requires": { - "mongodb-core": "3.1.3", + "mongodb-core": "3.1.4", "safe-buffer": "^5.1.2" } }, "mongodb-core": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-3.1.3.tgz", - "integrity": "sha512-dISiV3zHGJTwZpg0xDhi9zCqFGMhA5kDPByHlcaEp09NSKfzHJ7XQbqVrL7qhki1U9PZHsmRfbFzco+6b1h2wA==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-3.1.4.tgz", + "integrity": "sha512-T/8bLitfnnTv00P041gzgUMf9eyXoLnJsQjc0Ne7YEoUPLr3fMWERzhMx1MZ2GNjPF+7PzXVFwh30xE4YFNF3w==", "requires": { "bson": "^1.1.0", "require_optional": "^1.0.1", @@ -6412,10 +6418,13 @@ "dev": true }, "saslprep": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.1.tgz", - "integrity": "sha512-ntN6SbE3hRqd45PKKadRPgA+xHPWg5lPSj2JWJdJvjTwXDDfkPVtXWvP8jJojvnm+rAsZ2b299C5NwZqq818EA==", - "optional": true + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.2.tgz", + "integrity": "sha512-4cDsYuAjXssUSjxHKRe4DTZC0agDwsCqcMqtJAQPzC74nJ7LfAJflAtC1Zed5hMzEQKj82d3tuzqdGNRsLJ4Gw==", + "optional": true, + "requires": { + "sparse-bitfield": "^3.0.3" + } }, "semver": { "version": "5.5.1", @@ -6630,6 +6639,15 @@ } } }, + "sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", + "optional": true, + "requires": { + "memory-pager": "^1.0.2" + } + }, "spawn-sync": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz", diff --git a/package.json b/package.json index 5e21947fd..965f1241b 100644 --- a/package.json +++ b/package.json @@ -52,17 +52,17 @@ "src" ], "dependencies": { - "@loopback/boot": "^0.13.2", + "@loopback/boot": "^0.13.5", "@loopback/context": "^0.12.8", - "@loopback/core": "^0.11.8", + "@loopback/core": "^0.11.9", "@loopback/dist-util": "^0.3.7", - "@loopback/openapi-v3": "^0.13.2", - "@loopback/repository": "^0.16.2", - "@loopback/rest": "^0.21.1", + "@loopback/openapi-v3": "^0.14.2", + "@loopback/repository": "^0.16.5", + "@loopback/rest": "^0.22.2", "bcryptjs": "^2.4.3", "isemail": "^3.1.3", "loopback-connector-kv-redis": "^3.0.0", - "loopback-connector-mongodb": "^3.6.0" + "loopback-connector-mongodb": "^3.7.1" }, "devDependencies": { "@commitlint/cli": "^7.1.2", diff --git a/src/controllers/shopping-cart.controller.ts b/src/controllers/shopping-cart.controller.ts index 86447aa9e..36063e085 100644 --- a/src/controllers/shopping-cart.controller.ts +++ b/src/controllers/shopping-cart.controller.ts @@ -3,10 +3,18 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {put, get, del, param, requestBody, HttpErrors} from '@loopback/rest'; +import { + put, + get, + del, + param, + requestBody, + HttpErrors, + post, +} from '@loopback/rest'; import {repository} from '@loopback/repository'; import {ShoppingCartRepository} from '../repositories'; -import {ShoppingCart} from '../models'; +import {ShoppingCart, ShoppingCartItem} from '../models'; /** * Controller for shopping cart @@ -59,4 +67,17 @@ export class ShoppingCartController { async remove(@param.path.string('userId') userId: string) { await this.shoppingCartRepository.delete(userId); } + + /** + * Add an item to the shopping cart for a given user + * @param userId User id + * @param cart Shopping cart item to be added + */ + @post('/shoppingCarts/{userId}/items') + async addItem( + @param.path.string('userId') userId: string, + @requestBody({description: 'shopping cart item'}) item: ShoppingCartItem, + ) { + await this.shoppingCartRepository.addItem(userId, item); + } } diff --git a/src/models/shopping-cart.model.ts b/src/models/shopping-cart.model.ts index 409c89ea3..fc972008e 100644 --- a/src/models/shopping-cart.model.ts +++ b/src/models/shopping-cart.model.ts @@ -9,11 +9,11 @@ import {Entity, model, property} from '@loopback/repository'; * Item in a shopping cart */ @model() -export class ShoppingCartItem { +export class ShoppingCartItem extends Entity { /** * Product id */ - @property() + @property({id: true}) productId: string; /** * Quantity @@ -25,6 +25,10 @@ export class ShoppingCartItem { */ @property() price?: number; + + constructor(data?: Partial) { + super(data); + } } @model() diff --git a/src/repositories/shopping-cart.repository.ts b/src/repositories/shopping-cart.repository.ts index 6111e9eba..b3f3bd777 100644 --- a/src/repositories/shopping-cart.repository.ts +++ b/src/repositories/shopping-cart.repository.ts @@ -4,9 +4,10 @@ // License text available at https://opensource.org/licenses/MIT import {DefaultKeyValueRepository} from '@loopback/repository'; -import {ShoppingCart} from '../models/shopping-cart.model'; +import {ShoppingCart, ShoppingCartItem} from '../models/shopping-cart.model'; import {RedisDataSource} from '../datasources/redis.datasource'; import {inject} from '@loopback/context'; +import {promisify} from 'util'; export class ShoppingCartRepository extends DefaultKeyValueRepository< ShoppingCart @@ -14,4 +15,58 @@ export class ShoppingCartRepository extends DefaultKeyValueRepository< constructor(@inject('datasources.redis') ds: RedisDataSource) { super(ShoppingCart, ds); } + + /** + * Add an item to the shopping cart with optimistic lock to allow concurrent + * `adding to cart` from multiple devices + * + * @param userId User id + * @param item Item to be added + */ + addItem(userId: string, item: ShoppingCartItem) { + const addItemToCart = (cart: ShoppingCart | null) => { + cart = cart || new ShoppingCart({userId}); + cart.items = cart.items || []; + cart.items.push(item); + return cart; + }; + return this.checkAndSet(userId, addItemToCart); + } + + /** + * Use Redis WATCH and Transaction to check and set against a key + * See https://redis.io/topics/transactions#optimistic-locking-using-check-and-set + * + * Ideally, this method should be made available by `KeyValueRepository`. + * + * @param userId User id + * @param check A function that checks the current value and produces a new + * value. It returns `null` to abort. + */ + async checkAndSet( + userId: string, + check: (current: ShoppingCart | null) => ShoppingCart | null, + ) { + const connector = this.kvModelClass.dataSource!.connector!; + // tslint:disable-next-line:no-any + const execute = promisify((cmd: string, args: any[], cb: Function) => { + return connector.execute!(cmd, args, cb); + }); + /** + * - WATCH userId + * - GET userId + * - check(cart) + * - MULTI + * - SET userId + * - EXEC + */ + await execute('WATCH', [userId]); + let cart: ShoppingCart | null = await this.get(userId); + cart = check(cart); + if (!cart) return null; + await execute('MULTI', []); + await this.set(userId, cart); + await execute('EXEC', []); + return cart; + } } diff --git a/test/acceptance/shopping-cart.controller.acceptance.ts b/test/acceptance/shopping-cart.controller.acceptance.ts index feb3a1eb5..dc84adb0a 100644 --- a/test/acceptance/shopping-cart.controller.acceptance.ts +++ b/test/acceptance/shopping-cart.controller.acceptance.ts @@ -3,12 +3,12 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {createClientForHandler, supertest} from '@loopback/testlab'; +import {createClientForHandler, supertest, expect} from '@loopback/testlab'; import {RestServer} from '@loopback/rest'; import {ShoppingApplication} from '../..'; import {ShoppingCartRepository} from '../../src/repositories'; import {RedisDataSource} from '../../src/datasources'; -import {ShoppingCart} from '../../src/models'; +import {ShoppingCart, ShoppingCartItem} from '../../src/models'; describe('ShoppingCartController', () => { let app: ShoppingApplication; @@ -82,6 +82,25 @@ describe('ShoppingCartController', () => { await client.get(`/shoppingCarts/${cart.userId}`).expect(404); }); + it('adds a shopping cart item', async () => { + const cart = givenShoppingCart(); + const newItem = givenAnItem(); + // Set the shopping cart + await client + .put(`/shoppingCarts/${cart.userId}`) + .send(cart) + .expect(200); + // Now we can see it + await client + .post(`/shoppingCarts/${cart.userId}/items`) + .send(newItem) + .expect(200); + const newCart = (await client + .get(`/shoppingCarts/${cart.userId}`) + .expect(200)).body; + expect(newCart.items).to.containEql(newItem.toJSON()); + }); + function givenAnApplication() { app = new ShoppingApplication({ rest: { @@ -102,12 +121,20 @@ describe('ShoppingCartController', () => { return new ShoppingCart({ userId: 'user-0001', items: [ - { + new ShoppingCartItem({ productId: 'iPhone XS Max', quantity: 1, price: 1200, - }, + }), ], }); } + + function givenAnItem() { + return new ShoppingCartItem({ + productId: 'iPhone XS', + quantity: 2, + price: 2000, + }); + } }); diff --git a/test/integration/shopping-cart.repository.integration.ts b/test/integration/shopping-cart.repository.integration.ts index 81801e39c..0dcd0e136 100644 --- a/test/integration/shopping-cart.repository.integration.ts +++ b/test/integration/shopping-cart.repository.integration.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {ShoppingCartRepository} from '../../src/repositories'; -import {ShoppingCart} from '../../src/models'; +import {ShoppingCart, ShoppingCartItem} from '../../src/models'; import {expect} from '@loopback/testlab'; import {RedisDataSource} from '../../src/datasources'; @@ -30,9 +30,9 @@ describe('ShoppingCart KeyValue Repository', () => { it('gets data by key', async () => { let result = await repo.get(cart1.userId); - expect(result).to.eql(cart1); + expect(result.toJSON()).to.eql(cart1.toJSON()); result = await repo.get(cart2.userId); - expect(result).to.eql(cart2); + expect(result.toJSON()).to.eql(cart2.toJSON()); }); it('list keys', async () => { @@ -49,17 +49,28 @@ describe('ShoppingCart KeyValue Repository', () => { const result = await repo.get(cart1.userId); expect(result).to.be.null(); }); + + it('adds an item', async () => { + const item = new ShoppingCartItem({ + productId: 'p3', + quantity: 10, + price: 200, + }); + await repo.addItem(cart1.userId, item); + const result = await repo.get(cart1.userId); + expect(result.items).to.containEql(item.toJSON()); + }); }); function givenShoppingCart1() { return new ShoppingCart({ userId: 'u01', items: [ - { + new ShoppingCartItem({ productId: 'p1', quantity: 10, price: 100, - }, + }), ], }); } @@ -68,16 +79,16 @@ function givenShoppingCart2() { return new ShoppingCart({ userId: 'u02', items: [ - { + new ShoppingCartItem({ productId: 'p1', quantity: 1, price: 10, - }, - { + }), + new ShoppingCartItem({ productId: 'p2', quantity: 5, price: 20, - }, + }), ], }); }