From cb3b48749a59a6ff6418fe37fd61ac251c8e212c Mon Sep 17 00:00:00 2001 From: Sebastian Seggewiss Date: Tue, 5 Mar 2024 11:50:08 +0100 Subject: [PATCH] NEXT-33861 - Fix selectData --- .changeset/tough-geckos-drum.md | 5 + .../docs/guide/2_api-reference/data/get.md | 6 +- .../guide/2_api-reference/data/subscribe.md | 4 +- .../docs/docs/guide/4_concepts/selectors.md | 69 ++ .../src/_internals/data/selectData.spec.ts | 595 ++++++++++++++++++ .../src/_internals/data/selectData.ts | 250 ++++++-- packages/admin-sdk/src/channel.ts | 4 +- pnpm-lock.yaml | 6 +- 8 files changed, 868 insertions(+), 71 deletions(-) create mode 100644 .changeset/tough-geckos-drum.md create mode 100644 packages/admin-sdk/docs/docs/guide/4_concepts/selectors.md create mode 100644 packages/admin-sdk/src/_internals/data/selectData.spec.ts diff --git a/.changeset/tough-geckos-drum.md b/.changeset/tough-geckos-drum.md new file mode 100644 index 000000000..d98c09699 --- /dev/null +++ b/.changeset/tough-geckos-drum.md @@ -0,0 +1,5 @@ +--- +"@shopware-ag/meteor-admin-sdk": minor +--- + +- Changed from lodash get for selectors to own implementation which supports wildcards diff --git a/packages/admin-sdk/docs/docs/guide/2_api-reference/data/get.md b/packages/admin-sdk/docs/docs/guide/2_api-reference/data/get.md index c3cef48bb..4e1e75df3 100644 --- a/packages/admin-sdk/docs/docs/guide/2_api-reference/data/get.md +++ b/packages/admin-sdk/docs/docs/guide/2_api-reference/data/get.md @@ -17,6 +17,6 @@ data.get({ ``` #### Parameters -| Name | Required | Description | -| :-------- | :------- | :--------------------------------------------------------------------------------------------------------- | -| `options` | true | Options containing the unique `id` and optional `selectors` for minimizing the payload and needed privileges | +| Name | Required | Description | +| :-------- | :------- |:---------------------------------------------------------------------------------------------------------------------| +| `options` | true | Containing the unique `id` and optional `selectors`. Read more about selectors [here](../../4_concepts/selectors.md) | diff --git a/packages/admin-sdk/docs/docs/guide/2_api-reference/data/subscribe.md b/packages/admin-sdk/docs/docs/guide/2_api-reference/data/subscribe.md index bd30a67ae..663b4e5cb 100644 --- a/packages/admin-sdk/docs/docs/guide/2_api-reference/data/subscribe.md +++ b/packages/admin-sdk/docs/docs/guide/2_api-reference/data/subscribe.md @@ -16,7 +16,7 @@ data.subscribe( #### Parameters | Name | Required | Description | -| :---------- | :------- | :---------------------------------------------------------------------------------------------------- | +| :---------- | :------- |:------------------------------------------------------------------------------------------------------| | `id` | true | The unique id of the dataset you want to receive | | `callback` | true | A callback function which will be called every time the Shopware Administration publishes the dataset | -| `selectors` | false | Selectors for reducing the payload and minimizing the needed privileges | +| `selectors` | false | Read more about selectors [here](../../4_concepts/selectors.md) | diff --git a/packages/admin-sdk/docs/docs/guide/4_concepts/selectors.md b/packages/admin-sdk/docs/docs/guide/4_concepts/selectors.md new file mode 100644 index 000000000..7781f5605 --- /dev/null +++ b/packages/admin-sdk/docs/docs/guide/4_concepts/selectors.md @@ -0,0 +1,69 @@ +# Selectors + +Selectors are a powerful tool to reduce the payload and minimize the needed privileges. +They are used in `data.subscribe` and `data.get`. Selectors are an array of strings. Each string represents a path to a property in the dataset. + +### Example: + +Imagine this payload: +```json +{ + "name": "My Product", + "manufacturer": { + "name": "My Manufacturer" + }, + "price": 100, + "variants": [ + { + "name": "First Variant", + "price": 110 + }, + // contains more variants + ], + // contains more properties +} +``` + +If you are only interested in the names of the product and manufacturer, you can use the following selectors: +```javascript +data.get({ + id: 'sw-product-detail__product', + selectors: ['name', 'manufacturer.name'], +}).then((product) => { + console.log(product); // prints { name: "My Product", manufacturer: { name: "My Manufacturer" } } +}); +``` + +### Combining selectors + +Again for the above payload, if you are interested in multiple properties of the manufacturer, you can use the following selectors: +```javascript +data.get({ + id: 'sw-product-detail__product', + selectors: ['manufacturer.id', 'manufacturer.name'], +}).then((product) => { + console.log(product); // prints { manufacturer: { id: '065e71ab94d778a980008e8c3e890270', name: "My Manufacturer" } +}); +``` + +### Arrays + +If you are interested in a specific variant, you can use the following selectors: +```javascript +data.get({ + id: 'sw-product-detail__product', + selectors: ['variants.[0].name'], +}).then((product) => { + console.log(product); // prints { variants: [ { name: "First Variant" } ] } +}); +``` + +If you are interested in all variants, you can use wildcards. A wildcard is the asterix symbol (`*`) +```javascript +data.get({ + id: 'sw-product-detail__product', + selectors: ['variants.*.name'], +}).then((product) => { + console.log(product); // prints { variants: [ { name: "First Variant" }, // same structure for all entries ] } +}); +``` diff --git a/packages/admin-sdk/src/_internals/data/selectData.spec.ts b/packages/admin-sdk/src/_internals/data/selectData.spec.ts new file mode 100644 index 000000000..8c26d287b --- /dev/null +++ b/packages/admin-sdk/src/_internals/data/selectData.spec.ts @@ -0,0 +1,595 @@ +import { selectData } from './selectData'; + +describe('test selectData method', () => { + beforeAll(() => { + /** + * Mocking the global window "_swdsk" object + */ + window._swsdk = { + sourceRegistry: new Set(), + datasets: new Map(), + subscriberRegistry: new Set(), + adminExtensions: { + 'https://jestjs.io': { + baseUrl: 'https://jestjs.io', + permissions: { + additional: ['*'], + create: ['*'], + read: ['*'], + update: ['*'], + delete: ['*'], + }, + }, + }, + }; + }); + + it('should select string data with one path', () => { + const sourceData = { + a: { + b: { + c: 'c', + }, + }, + }; + + const selectors = ['a.b.c']; + + const selectedData = selectData( + sourceData, + selectors, + undefined, + 'https://jestjs.io' + ); + + expect(selectedData).toEqual({ + a: { + b: { + c: 'c', + }, + }, + }); + }); + + it('should select object data with one path', () => { + const sourceData = { + a: { + b: { + c: { + d: 'd', + }, + }, + }, + }; + + const selectors = [ + 'a.b.c', + ]; + + const selectedData = selectData( + sourceData, + selectors, + undefined, + 'https://jestjs.io' + ); + + expect(selectedData).toEqual({ + a: { + b: { + c: { + d: 'd', + }, + }, + }, + }); + }); + + it('should select data with multiple paths', () => { + const sourceData = { + a: { + b: { + c: 'c', + }, + }, + d: { + e: 'e', + }, + }; + + const selectors = [ + 'a.b.c', + 'd.e', + ]; + + const selectedData = selectData( + sourceData, + selectors, + undefined, + 'https://jestjs.io' + ); + + expect(selectedData).toEqual({ + a: { + b: { + c: 'c', + }, + }, + d: { + e: 'e', + }, + }); + }); + + it('should select data with multiple paths and one of them is not found', () => { + const sourceData = { + a: { + b: { + c: 'c', + }, + }, + }; + + const selectors = [ + 'a.b.c', + 'd.e', + ]; + + const selectedData = selectData( + sourceData, + selectors, + undefined, + 'https://jestjs.io' + ); + + expect(selectedData).toEqual({ + a: { + b: { + c: 'c', + }, + }, + d: { + e: undefined, + }, + }); + }); + + it('should select array data with one path', () => { + const sourceData = { + a: { + b: { + c: ['c', 'd'], + }, + }, + }; + + const selectors = [ + 'a.b.c', + ]; + + const selectedData = selectData( + sourceData, + selectors, + undefined, + 'https://jestjs.io' + ); + + expect(selectedData).toEqual({ + a: { + b: { + c: ['c', 'd'], + }, + }, + }); + }); + + it('should select array data', () => { + const sourceData = { + a: { + b: { + c: ['c', 'd'], + }, + }, + }; + + const selectors = ['a.b.c.*']; + + const selectedData = selectData( + sourceData, + selectors, + undefined, + 'https://jestjs.io' + ); + + expect(selectedData).toEqual({ + a: { + b: { + c: ['c', 'd'], + }, + }, + }); + }); + + it('should select array data with one path and one of the values is an object', () => { + const sourceData = { + a: { + b: { + c: ['c', { d: 'd' }], + }, + }, + }; + + const selectors = ['a.b.c']; + + const selectedData = selectData( + sourceData, + selectors, + undefined, + 'https://jestjs.io' + ); + + expect(selectedData).toEqual({ + a: { + b: { + c: ['c', { d: 'd' }], + }, + }, + }); + }); + + it('should select all values from array objects', () => { + const sourceData = { + products: [ + { + id: 1, + name: 'Product 1', + }, + { + id: 2, + name: 'Product 2', + }, + { + id: 3, + name: 'Product 3', + }, + { + id: 4, + name: 'Product 4', + }, + ], + }; + + const selectors = [ + 'products.*', + ]; + + const selectedData = selectData( + sourceData, + selectors, + undefined, + 'https://jestjs.io' + ); + + expect(selectedData).toEqual({ + products: [ + { id: 1, name: 'Product 1' }, + { id: 2, name: 'Product 2' }, + { id: 3, name: 'Product 3' }, + { id: 4, name: 'Product 4' }, + ], + }); + }); + + it('should select from array object', () => { + const sourceData = { + products: [ + { + id: 1, + name: 'Product 1', + }, + { + id: 2, + name: 'Product 2', + }, + { + id: 3, + name: 'Product 3', + }, + { + id: 4, + name: 'Product 4', + }, + ], + }; + + const selectors = [ + 'products.*.id', + ]; + + const selectedData = selectData( + sourceData, + selectors, + undefined, + 'https://jestjs.io' + ); + + expect(selectedData).toEqual({ + products: [ + { id: 1 }, + { id: 2 }, + { id: 3 }, + { id: 4 }, + ], + }); + }); + + it('should select multiple filtered values from array objects and one of the values is not found', () => { + const sourceData = { + products: [ + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + }, + { + id: 4, + }, + ], + }; + + const selectors = [ + 'products.*.id', + 'products.*.price', + ]; + + const selectedData = selectData( + sourceData, + selectors, + undefined, + 'https://jestjs.io' + ); + + expect(selectedData).toEqual({ + products: [ + { id: 1, price: undefined }, + { id: 2, price: undefined }, + { id: 3, price: undefined }, + { id: 4, price: undefined }, + ], + }); + }); + + it('should select multiple filtered values from array objects', () => { + const sourceData = { + products: [ + { + id: 1, + name: 'Product 1', + }, + { + id: 2, + name: 'Product 2', + }, + { + id: 3, + name: 'Product 3', + }, + { + id: 4, + name: 'Product 4', + }, + ], + }; + + const selectors = [ + 'products.*.id', + 'products.*.name', + ]; + + const selectedData = selectData( + sourceData, + selectors, + undefined, + 'https://jestjs.io' + ); + + expect(selectedData).toEqual({ + products: [ + { id: 1, name: 'Product 1' }, + { id: 2, name: 'Product 2' }, + { id: 3, name: 'Product 3' }, + { id: 4, name: 'Product 4' }, + ], + }); + }); + + it('should select multiple filtered values from nested array objects and return it in one object', () => { + const sourceData = { + name: 'Test', + deep: { + foo: { + value: 'Jest', + }, + }, + products: [ + { + id: 1, + name: 'Product 1', + price: 100, + variants: [ + { + id: 1, + name: 'Variant 1', + }, + { + id: 2, + name: 'Variant 2', + }, + ], + }, + { + id: 2, + name: 'Product 2', + price: 200, + variants: [ + { + id: 3, + name: 'Variant 3', + }, + { + id: 4, + name: 'Variant 4', + }, + ], + }, + { + id: 3, + name: 'Product 3', + price: 300, + variants: [ + { + id: 5, + name: 'Variant 5', + }, + { + id: 6, + name: 'Variant 6', + }, + ], + }, + { + id: 4, + name: 'Product 4', + price: 400, + variants: [ + { + id: 7, + name: 'Variant 7', + }, + { + id: 8, + name: 'Variant 8', + }, + ], + }, + ], + }; + + const selectors = [ + 'name', + 'deep.foo.value', + 'products.*.id', + 'products.*.name', + 'products.*.variants.*.id', + ]; + + const selectedData = selectData( + sourceData, + selectors, + undefined, + 'https://jestjs.io' + ); + + expect(selectedData).toEqual({ + name: 'Test', + deep: { + foo: { + value: 'Jest', + }, + }, + products: [ + { + id: 1, + name: 'Product 1', + variants: [ + { + id: 1, + }, + { + id: 2, + }, + ], + }, + { + id: 2, + name: 'Product 2', + variants: [ + { + id: 3, + }, + { + id: 4, + }, + ], + }, + { + id: 3, + name: 'Product 3', + variants: [ + { + id: 5, + }, + { + id: 6, + }, + ], + }, + { + id: 4, + name: 'Product 4', + variants: [ + { + id: 7, + }, + { + id: 8, + }, + ], + }, + ], + }); + }); + + it('should select specific value from array', () => { + const sourceData = { + products: [ + { + id: 1, + name: 'Product 1', + price: 100, + }, + { + id: 2, + name: 'Product 2', + price: 200, + }, + ], + }; + + const selectors = [ + 'products.[0].name', + 'products.[1].id', + ]; + + const selectedData = selectData( + sourceData, + selectors, + undefined, + 'https://jestjs.io' + ); + + expect(selectedData).toEqual({ + products: [ + { + name: 'Product 1', + }, + { + id: 2, + }, + ], + }); + }); +}); diff --git a/packages/admin-sdk/src/_internals/data/selectData.ts b/packages/admin-sdk/src/_internals/data/selectData.ts index b713fa2d8..80090a08a 100644 --- a/packages/admin-sdk/src/_internals/data/selectData.ts +++ b/packages/admin-sdk/src/_internals/data/selectData.ts @@ -1,87 +1,211 @@ -import { toPath, get } from 'lodash'; import type { ShopwareMessageTypes } from '../../message-types'; -import MissingPrivilegesError from '../privileges/missing-privileges-error'; -import type { privilegeString } from '../privileges'; import { findExtensionByBaseUrl } from '../utils'; +import type { privilegeString, extension } from '../privileges'; +import MissingPrivilegesError from '../privileges/missing-privileges-error'; +/** + * Selects data from a source object using a list of selectors. + */ export function selectData( - sourceData: unknown, + sourceData: Record, selectors?: string[], messageType: keyof ShopwareMessageTypes = 'datasetSubscribe', - origin?: string, + origin = '', ): unknown { + if (!selectors) { + return sourceData; + } + + const result = {}; const extension = findExtensionByBaseUrl(origin ?? ''); const permissionErrors: Array = []; - if (!selectors) { - return sourceData; + // Iterate through all selectors, e.g. ['a.b.c', 'd.e.f'] + selectors.forEach((selector) => { + selectValue( + sourceData, + selector, + extension, + permissionErrors, + origin, + messageType, + result + ); + }); + + if (!extension) { + console.warn(`No extension found for origin "${origin}"`); + return result; } - const selectedData = selectors.reduce<{ - [key: string]: unknown, - }>((acc, selector) => { - // Check permissions for path entries - ['', ...toPath(selector)].forEach((path) => { - const value = path !== '' ? get(sourceData, path) as unknown : sourceData; - let entityName = ''; - - // @ts-expect-error - we just check if the value is an entity - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - if (value && value.__identifier__ && value.__identifier__() === 'Entity') { - // @ts-expect-error - we know that the value is an entity - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call - entityName = value.getEntityName(); - } + if (permissionErrors.length) { + return new MissingPrivilegesError(messageType, permissionErrors); + } - // @ts-expect-error - we just check if the value is an entityCollection - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - if (value && value.__identifier__ && value.__identifier__() === 'EntityCollection') { - // @ts-expect-error - we know that the value is an entityCollection - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call - entityName = value.entity; - } + return result; +} + +/** + * Adds the structure and value of the selector to the result object. + * Also checks if the extension has the required permissions for the given data. + */ +function selectValue( + data: Record, + selector: string, + extension: extension|undefined, + permissionErrors: Array, + origin: string, + messageType: keyof ShopwareMessageTypes, + result: Record = {} +): Record { + const parts = selector.split('.'); + + let tmpResult = result; + let tmpData = data; + + // Iterate through all parts of the selector, e.g. ['products', '*', 'name'] + for (let i = 0; i < parts.length; i++) { + const specificArrayMatcher = /\[\d*\]/; + const currentPart = parts[i]; + const nextPart = parts[i + 1]; + + // Next part is wildcard or specific array selector + if (nextPart && (nextPart === '*' || nextPart.match(specificArrayMatcher))) { + // No part after the wildcard? Add the whole array to the result + if (!parts[i +2]) { + // Check parent object permissions + checkPermission(tmpData, extension, permissionErrors); + // Check requested value permissions + checkPermission(tmpData?.[currentPart], extension, permissionErrors); - if (!entityName) { - return; + tmpResult[currentPart] = tmpData?.[currentPart] as Record; + break; + } + + // Set next value as existing array or create a new one + tmpResult[currentPart] = tmpResult[currentPart] ?? []; + tmpData = tmpData?.[currentPart] as Record; + tmpResult = tmpResult[currentPart] as Record; + continue; + } + + // Setting data into an array with a following selector + if (Array.isArray(tmpData) && nextPart) { + const selectorAfterCurrent = parts.slice(i+1).join('.'); + + // Current part was a wildcard? Add the value for all array entries + if (currentPart === '*') { + for(let j=0; j, + selectorAfterCurrent, + extension, + permissionErrors, + origin, + messageType, + // Result is either the root array or the existing array entry + (tmpResult[j] ?? tmpResult) as Record + ); } + break; + } - const permissionsToCheck = ['read'] as const; - - permissionsToCheck.forEach((privilege) => { - const permissionsForPrivilege = extension?.permissions[privilege]; - - if ( - ( - !permissionsForPrivilege || - !permissionsForPrivilege.includes(entityName) - ) - && - !permissionErrors.includes(`${privilege}:${entityName}`) - && - !permissionsForPrivilege?.includes('*') - ) { - permissionErrors.push(`${privilege}:${entityName}`); - } - }); - }); - - const value = get(sourceData, selector) as unknown; - - if (value !== undefined) { - acc[selector] = value; + // Current part was a specific array index? Add the value for the specific array entry + if (currentPart.match(specificArrayMatcher)) { + const index = parseArrayIndex(currentPart); + selectValue( + tmpData[index] as Record, + selectorAfterCurrent, + extension, + permissionErrors, + origin, + messageType, + // Result is either the root array or the existing array entry + (tmpResult[index] ?? tmpResult) as Record + ); + break; } + } - return acc; - }, {}); + // Is the current part the last of the selector? + if (i === parts.length - 1) { + // Check parent object permissions + checkPermission(tmpData, extension, permissionErrors); + // Check requested value permissions + checkPermission(tmpData?.[currentPart], extension, permissionErrors); - if (!extension) { - console.warn(`No extension found for origin "${origin ?? ''}"`); - return selectedData; + // Add value to array + if (Array.isArray(tmpResult)) { + tmpResult.push({ [currentPart]: tmpData?.[currentPart] }); + break; + } + + // Adds the value to object structures + tmpResult[currentPart] = tmpData?.[currentPart]; + break; + } + + // Move to next level + tmpResult[currentPart] = {}; + tmpData = tmpData?.[currentPart] as Record; + tmpResult = tmpResult[currentPart] as Record; } - if (permissionErrors.length) { - return new MissingPrivilegesError(messageType, permissionErrors); + return result; +} + +/** + * Checks if the extension has the required permissions for the given data. + */ +function checkPermission(data: unknown, extension: extension|undefined, permissionErrors: Array): void { + if (!data) { + return; + } + + const permissionsToCheck = ['read'] as const; + let entityName = ''; + + // @ts-expect-error - we just check if the value is an entity + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + if (data.__identifier__ && data.__identifier__() === 'Entity') { + // @ts-expect-error - we know that the value is an entity + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + entityName = data.getEntityName(); + } + + // @ts-expect-error - we just check if the value is an entityCollection + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + if (data.__identifier__ && data.__identifier__() === 'EntityCollection') { + // @ts-expect-error - we know that the value is an entityCollection + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + entityName = data.entity; } + + if (!entityName) { + return; + } + + permissionsToCheck.forEach((privilege) => { + const permissionsForPrivilege = extension?.permissions?.[privilege]; + + if ( + ( + !permissionsForPrivilege || + !permissionsForPrivilege.includes(entityName) + ) + && + !permissionErrors.includes(`${privilege}:${entityName}`) + && + !permissionsForPrivilege?.includes('*') + ) { + permissionErrors.push(`${privilege}:${entityName}`); + } + }); +} - return selectedData; +/** + * Parses the array index from a string. + */ +function parseArrayIndex(index: string): number { + return Number.parseInt(index.replace('[', '').replace(']', '')); } diff --git a/packages/admin-sdk/src/channel.ts b/packages/admin-sdk/src/channel.ts index d857fd258..6a618eead 100644 --- a/packages/admin-sdk/src/channel.ts +++ b/packages/admin-sdk/src/channel.ts @@ -541,7 +541,7 @@ const datasets = new Map(); const dataset = datasets.get(data.id); if (dataset) { - const selectedData = selectData(dataset, data.selectors, 'datasetSubscribe', origin); + const selectedData = selectData(dataset as Record, data.selectors, 'datasetSubscribe', origin); if (selectedData instanceof MissingPrivilegesError) { console.error(selectedData); @@ -572,7 +572,7 @@ export async function processDataRegistration(data: Omit, selectors, 'datasetSubscribe', origin); if (selectedData instanceof MissingPrivilegesError) { console.error(selectedData); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05e87873c..70c6e1849 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5150,7 +5150,7 @@ packages: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.3.8(typescript@5.2.2) - vue-component-type-helpers: 1.8.27 + vue-component-type-helpers: 2.0.5 transitivePeerDependencies: - encoding - supports-color @@ -16304,6 +16304,10 @@ packages: resolution: {integrity: sha512-0vOfAtI67UjeO1G6UiX5Kd76CqaQ67wrRZiOe7UAb9Jm6GzlUr/fC7CV90XfwapJRjpCMaZFhv1V0ajWRmE9Dg==} dev: true + /vue-component-type-helpers@2.0.5: + resolution: {integrity: sha512-v9N4ufDSnd8YHcDq/vURPjxDyBVak5ZVAQ6aGNIrf7ZAj/VxRKpLZXFHEaqt9yHkWi0/TZp76Jmf8yNJxDQi4g==} + dev: true + /vue-demi@0.14.7(vue@3.3.8): resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==} engines: {node: '>=12'}