@strapi/utils 4.9.0-alpha.0 → 4.9.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -99,8 +99,8 @@ const getPrivateAttributes = (model = {}) => {
99
99
  );
100
100
  };
101
101
 
102
- const isPrivateAttribute = (model = {}, attributeName) => {
103
- return model && model.privateAttributes && model.privateAttributes.includes(attributeName);
102
+ const isPrivateAttribute = (model, attributeName) => {
103
+ return model?.privateAttributes?.includes(attributeName) ?? false;
104
104
  };
105
105
 
106
106
  const isScalarAttribute = (attribute) => {
@@ -232,7 +232,8 @@ const convertPopulateObject = (populate, schema) => {
232
232
  isMediaAttribute(attribute) ||
233
233
  isMorphToRelationalAttribute(attribute);
234
234
 
235
- const hasFragmentPopulateDefined = typeof subPopulate === 'object' && 'on' in subPopulate;
235
+ const hasFragmentPopulateDefined =
236
+ typeof subPopulate === 'object' && 'on' in subPopulate && !isNil(subPopulate.on);
236
237
 
237
238
  if (isAllowedAttributeForFragmentPopulate && hasFragmentPopulateDefined) {
238
239
  return {
@@ -418,7 +419,7 @@ const convertAndSanitizeFilters = (filters, schema) => {
418
419
 
419
420
  // Here, `key` can either be an operator or an attribute name
420
421
  for (const [key, value] of Object.entries(filters)) {
421
- const attribute = get(key, schema.attributes);
422
+ const attribute = get(key, schema?.attributes);
422
423
 
423
424
  // Handle attributes
424
425
  if (attribute) {
package/lib/env-helper.js CHANGED
@@ -71,6 +71,26 @@ const utils = {
71
71
  const value = process.env[key];
72
72
  return new Date(value);
73
73
  },
74
+
75
+ /**
76
+ * Gets a value from env that matches oneOf provided values
77
+ * @param {string} key
78
+ * @param {string[]} expectedValues
79
+ * @param {string|undefined} defaultValue
80
+ * @returns {string|undefined}
81
+ */
82
+ oneOf(key, expectedValues, defaultValue) {
83
+ if (!expectedValues) {
84
+ throw new Error(`env.oneOf requires expectedValues`);
85
+ }
86
+
87
+ if (defaultValue && !expectedValues.includes(defaultValue)) {
88
+ throw new Error(`env.oneOf requires defaultValue to be included in expectedValues`);
89
+ }
90
+
91
+ const rawValue = env(key, defaultValue);
92
+ return expectedValues.includes(rawValue) ? rawValue : defaultValue;
93
+ },
74
94
  };
75
95
 
76
96
  Object.assign(env, utils);
package/lib/index.js CHANGED
@@ -42,6 +42,7 @@ const convertQueryParams = require('./convert-query-params');
42
42
  const importDefault = require('./import-default');
43
43
  const template = require('./template');
44
44
  const file = require('./file');
45
+ const traverse = require('./traverse');
45
46
 
46
47
  module.exports = {
47
48
  yup,
@@ -91,4 +92,5 @@ module.exports = {
91
92
  convertQueryParams,
92
93
  importDefault,
93
94
  file,
95
+ traverse,
94
96
  };
@@ -1,60 +1,138 @@
1
1
  'use strict';
2
2
 
3
- const { isArray } = require('lodash/fp');
3
+ const { isArray, cloneDeep } = require('lodash/fp');
4
4
 
5
- const traverseEntity = require('../traverse-entity');
6
5
  const { getNonWritableAttributes } = require('../content-types');
7
6
  const { pipeAsync } = require('../async');
8
7
 
9
8
  const visitors = require('./visitors');
10
9
  const sanitizers = require('./sanitizers');
10
+ const traverseEntity = require('../traverse-entity');
11
+
12
+ const { traverseQueryFilters, traverseQuerySort, traverseQueryPopulate } = require('../traverse');
13
+
14
+ const createContentAPISanitizers = () => {
15
+ const sanitizeInput = (data, schema, { auth } = {}) => {
16
+ if (isArray(data)) {
17
+ return Promise.all(data.map((entry) => sanitizeInput(entry, schema, { auth })));
18
+ }
19
+
20
+ const nonWritableAttributes = getNonWritableAttributes(schema);
21
+
22
+ const transforms = [
23
+ // Remove non writable attributes
24
+ traverseEntity(visitors.restrictedFields(nonWritableAttributes), { schema }),
25
+ ];
26
+
27
+ if (auth) {
28
+ // Remove restricted relations
29
+ transforms.push(traverseEntity(visitors.removeRestrictedRelations(auth), { schema }));
30
+ }
31
+
32
+ // Apply sanitizers from registry if exists
33
+ strapi.sanitizers
34
+ .get('content-api.input')
35
+ .forEach((sanitizer) => transforms.push(sanitizer(schema)));
36
+
37
+ return pipeAsync(...transforms)(data);
38
+ };
39
+
40
+ const sanitizeOuput = (data, schema, { auth } = {}) => {
41
+ if (isArray(data)) {
42
+ return Promise.all(data.map((entry) => sanitizeOuput(entry, schema, { auth })));
43
+ }
44
+
45
+ const transforms = [sanitizers.defaultSanitizeOutput(schema)];
46
+
47
+ if (auth) {
48
+ transforms.push(traverseEntity(visitors.removeRestrictedRelations(auth), { schema }));
49
+ }
50
+
51
+ // Apply sanitizers from registry if exists
52
+ strapi.sanitizers
53
+ .get('content-api.output')
54
+ .forEach((sanitizer) => transforms.push(sanitizer(schema)));
55
+
56
+ return pipeAsync(...transforms)(data);
57
+ };
58
+
59
+ const sanitizeQuery = async (query, schema, { auth } = {}) => {
60
+ const { filters, sort, fields, populate } = query;
61
+
62
+ const sanitizedQuery = cloneDeep(query);
63
+
64
+ if (filters) {
65
+ Object.assign(sanitizedQuery, { filters: await sanitizeFilters(filters, schema, { auth }) });
66
+ }
67
+
68
+ if (sort) {
69
+ Object.assign(sanitizedQuery, { sort: await sanitizeSort(sort, schema, { auth }) });
70
+ }
71
+
72
+ if (fields) {
73
+ Object.assign(sanitizedQuery, { fields: await sanitizeFields(fields, schema) });
74
+ }
75
+
76
+ if (populate) {
77
+ Object.assign(sanitizedQuery, { populate: await sanitizePopulate(populate, schema) });
78
+ }
79
+
80
+ return sanitizedQuery;
81
+ };
82
+
83
+ const sanitizeFilters = (filters, schema, { auth } = {}) => {
84
+ if (isArray(filters)) {
85
+ return Promise.all(filters.map((filter) => sanitizeFilters(filter, schema, { auth })));
86
+ }
87
+
88
+ const transforms = [sanitizers.defaultSanitizeFilters(schema)];
89
+
90
+ if (auth) {
91
+ transforms.push(traverseQueryFilters(visitors.removeRestrictedRelations(auth), { schema }));
92
+ }
93
+
94
+ return pipeAsync(...transforms)(filters);
95
+ };
96
+
97
+ const sanitizeSort = (sort, schema, { auth } = {}) => {
98
+ const transforms = [sanitizers.defaultSanitizeSort(schema)];
99
+
100
+ if (auth) {
101
+ transforms.push(traverseQuerySort(visitors.removeRestrictedRelations(auth), { schema }));
102
+ }
103
+
104
+ return pipeAsync(...transforms)(sort);
105
+ };
106
+
107
+ const sanitizeFields = (fields, schema) => {
108
+ const transforms = [sanitizers.defaultSanitizeFields(schema)];
109
+
110
+ return pipeAsync(...transforms)(fields);
111
+ };
112
+
113
+ const sanitizePopulate = (populate, schema, { auth } = {}) => {
114
+ const transforms = [sanitizers.defaultSanitizePopulate(schema)];
115
+
116
+ if (auth) {
117
+ transforms.push(traverseQueryPopulate(visitors.removeRestrictedRelations(auth), { schema }));
118
+ }
119
+
120
+ return pipeAsync(...transforms)(populate);
121
+ };
122
+
123
+ return {
124
+ input: sanitizeInput,
125
+ output: sanitizeOuput,
126
+ query: sanitizeQuery,
127
+ filters: sanitizeFilters,
128
+ sort: sanitizeSort,
129
+ fields: sanitizeFields,
130
+ populate: sanitizePopulate,
131
+ };
132
+ };
11
133
 
12
134
  module.exports = {
13
- contentAPI: {
14
- input(data, schema, { auth } = {}) {
15
- if (isArray(data)) {
16
- return Promise.all(data.map((entry) => this.input(entry, schema, { auth })));
17
- }
18
-
19
- const nonWritableAttributes = getNonWritableAttributes(schema);
20
-
21
- const transforms = [
22
- // Remove non writable attributes
23
- traverseEntity(visitors.restrictedFields(nonWritableAttributes), { schema }),
24
- ];
25
-
26
- if (auth) {
27
- // Remove restricted relations
28
- transforms.push(traverseEntity(visitors.removeRestrictedRelations(auth), { schema }));
29
- }
30
-
31
- // Apply sanitizers from registry if exists
32
- strapi.sanitizers
33
- .get('content-api.input')
34
- .forEach((sanitizer) => transforms.push(sanitizer(schema)));
35
-
36
- return pipeAsync(...transforms)(data);
37
- },
38
-
39
- output(data, schema, { auth } = {}) {
40
- if (isArray(data)) {
41
- return Promise.all(data.map((entry) => this.output(entry, schema, { auth })));
42
- }
43
-
44
- const transforms = [sanitizers.defaultSanitizeOutput(schema)];
45
-
46
- if (auth) {
47
- transforms.push(traverseEntity(visitors.removeRestrictedRelations(auth), { schema }));
48
- }
49
-
50
- // Apply sanitizers from registry if exists
51
- strapi.sanitizers
52
- .get('content-api.output')
53
- .forEach((sanitizer) => transforms.push(sanitizer(schema)));
54
-
55
- return pipeAsync(...transforms)(data);
56
- },
57
- },
135
+ contentAPI: createContentAPISanitizers(),
58
136
 
59
137
  sanitizers,
60
138
  visitors,
@@ -1,11 +1,24 @@
1
1
  'use strict';
2
2
 
3
- const { curry } = require('lodash/fp');
3
+ const { curry, isEmpty, isNil, isArray, isObject } = require('lodash/fp');
4
4
 
5
5
  const { pipeAsync } = require('../async');
6
6
  const traverseEntity = require('../traverse-entity');
7
+ const { isScalarAttribute } = require('../content-types');
7
8
 
8
- const { removePassword, removePrivate } = require('./visitors');
9
+ const {
10
+ traverseQueryFilters,
11
+ traverseQuerySort,
12
+ traverseQueryPopulate,
13
+ traverseQueryFields,
14
+ } = require('../traverse');
15
+
16
+ const {
17
+ removePassword,
18
+ removePrivate,
19
+ removeDynamicZones,
20
+ removeMorphToRelations,
21
+ } = require('./visitors');
9
22
 
10
23
  const sanitizePasswords = curry((schema, entity) => {
11
24
  return traverseEntity(removePassword, { schema }, entity);
@@ -19,8 +32,118 @@ const defaultSanitizeOutput = curry((schema, entity) => {
19
32
  return pipeAsync(sanitizePrivates(schema), sanitizePasswords(schema))(entity);
20
33
  });
21
34
 
35
+ const defaultSanitizeFilters = curry((schema, filters) => {
36
+ return pipeAsync(
37
+ // Remove dynamic zones from filters
38
+ traverseQueryFilters(removeDynamicZones, { schema }),
39
+ // Remove morpTo relations from filters
40
+ traverseQueryFilters(removeMorphToRelations, { schema }),
41
+ // Remove passwords from filters
42
+ traverseQueryFilters(removePassword, { schema }),
43
+ // Remove private from filters
44
+ traverseQueryFilters(removePrivate, { schema }),
45
+ // Remove empty objects
46
+ traverseQueryFilters(
47
+ ({ key, value }, { remove }) => {
48
+ if (isObject(value) && isEmpty(value)) {
49
+ remove(key);
50
+ }
51
+ },
52
+ { schema }
53
+ )
54
+ )(filters);
55
+ });
56
+
57
+ const defaultSanitizeSort = curry((schema, sort) => {
58
+ return pipeAsync(
59
+ // Remove non attribute keys
60
+ traverseQuerySort(
61
+ ({ key, attribute }, { remove }) => {
62
+ // ID is not an attribute per se, so we need to make
63
+ // an extra check to ensure we're not removing it
64
+ if (key === 'id') {
65
+ return;
66
+ }
67
+
68
+ if (!attribute) {
69
+ remove(key);
70
+ }
71
+ },
72
+ { schema }
73
+ ),
74
+ // Remove dynamic zones from sort
75
+ traverseQuerySort(removeDynamicZones, { schema }),
76
+ // Remove morpTo relations from sort
77
+ traverseQuerySort(removeMorphToRelations, { schema }),
78
+ // Remove private from sort
79
+ traverseQuerySort(removePrivate, { schema }),
80
+ // Remove passwords from filters
81
+ traverseQuerySort(removePassword, { schema }),
82
+ // Remove keys for empty non-scalar values
83
+ traverseQuerySort(
84
+ ({ key, attribute, value }, { remove }) => {
85
+ if (!isScalarAttribute(attribute) && isEmpty(value)) {
86
+ remove(key);
87
+ }
88
+ },
89
+ { schema }
90
+ )
91
+ )(sort);
92
+ });
93
+
94
+ const defaultSanitizeFields = curry((schema, fields) => {
95
+ return pipeAsync(
96
+ // Only keep scalar attributes
97
+ traverseQueryFields(
98
+ ({ key, attribute }, { remove }) => {
99
+ if (isNil(attribute) || !isScalarAttribute(attribute)) {
100
+ remove(key);
101
+ }
102
+ },
103
+ { schema }
104
+ ),
105
+ // Remove private fields
106
+ traverseQueryFields(removePrivate, { schema }),
107
+ // Remove password fields
108
+ traverseQueryFields(removePassword, { schema }),
109
+ // Remove nil values from fields array
110
+ (value) => (isArray(value) ? value.filter((field) => !isNil(field)) : value)
111
+ )(fields);
112
+ });
113
+
114
+ const defaultSanitizePopulate = curry((schema, populate) => {
115
+ return pipeAsync(
116
+ traverseQueryPopulate(
117
+ async ({ key, value, schema, attribute }, { set }) => {
118
+ if (attribute) {
119
+ return;
120
+ }
121
+
122
+ if (key === 'sort') {
123
+ set(key, await defaultSanitizeSort(schema, value));
124
+ }
125
+
126
+ if (key === 'filters') {
127
+ set(key, await defaultSanitizeFilters(schema, value));
128
+ }
129
+
130
+ if (key === 'fields') {
131
+ set(key, await defaultSanitizeFields(schema, value));
132
+ }
133
+ },
134
+ { schema }
135
+ ),
136
+ // Remove private fields
137
+ traverseQueryPopulate(removePrivate, { schema })
138
+ )(populate);
139
+ });
140
+
22
141
  module.exports = {
23
142
  sanitizePasswords,
24
143
  sanitizePrivates,
25
144
  defaultSanitizeOutput,
145
+ defaultSanitizeFilters,
146
+ defaultSanitizeSort,
147
+ defaultSanitizeFields,
148
+ defaultSanitizePopulate,
26
149
  };
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
 
3
- const { isArray, toPath } = require('lodash/fp');
3
+ const { isArray, isNil, toPath } = require('lodash/fp');
4
4
 
5
5
  module.exports =
6
6
  (allowedFields = null) =>
7
- ({ key, path }, { remove }) => {
7
+ ({ key, path: { attribute: path } }, { remove }) => {
8
8
  // All fields are allowed
9
9
  if (allowedFields === null) {
10
10
  return;
@@ -15,6 +15,10 @@ module.exports =
15
15
  return;
16
16
  }
17
17
 
18
+ if (isNil(path)) {
19
+ return;
20
+ }
21
+
18
22
  const containedPaths = getContainedPaths(path);
19
23
 
20
24
  /**
@@ -4,6 +4,8 @@ module.exports = {
4
4
  removePassword: require('./remove-password'),
5
5
  removePrivate: require('./remove-private'),
6
6
  removeRestrictedRelations: require('./remove-restricted-relations'),
7
+ removeMorphToRelations: require('./remove-morph-to-relations'),
8
+ removeDynamicZones: require('./remove-dynamic-zones'),
7
9
  allowedFields: require('./allowed-fields'),
8
10
  restrictedFields: require('./restricted-fields'),
9
11
  };
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ const { isDynamicZoneAttribute } = require('../../content-types');
4
+
5
+ module.exports = ({ key, attribute }, { remove }) => {
6
+ if (isDynamicZoneAttribute(attribute)) {
7
+ remove(key);
8
+ }
9
+ };
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ const { isMorphToRelationalAttribute } = require('../../content-types');
4
+
5
+ module.exports = ({ key, attribute }, { remove }) => {
6
+ if (isMorphToRelationalAttribute(attribute)) {
7
+ remove(key);
8
+ }
9
+ };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  module.exports = ({ key, attribute }, { remove }) => {
4
- if (attribute.type === 'password') {
4
+ if (attribute?.type === 'password') {
5
5
  remove(key);
6
6
  }
7
7
  };
@@ -2,8 +2,12 @@
2
2
 
3
3
  const { isPrivateAttribute } = require('../../content-types');
4
4
 
5
- module.exports = ({ schema, key }, { remove }) => {
6
- const isPrivate = isPrivateAttribute(schema, key);
5
+ module.exports = ({ schema, key, attribute }, { remove }) => {
6
+ if (!attribute) {
7
+ return;
8
+ }
9
+
10
+ const isPrivate = isPrivateAttribute(schema, key) || attribute.private === true;
7
11
 
8
12
  if (isPrivate) {
9
13
  remove(key);
@@ -7,6 +7,10 @@ const { CREATED_BY_ATTRIBUTE, UPDATED_BY_ATTRIBUTE } = require('../../content-ty
7
7
  module.exports =
8
8
  (auth) =>
9
9
  async ({ data, key, attribute, schema }, { remove, set }) => {
10
+ if (!attribute) {
11
+ return;
12
+ }
13
+
10
14
  const isRelation = attribute.type === 'relation';
11
15
 
12
16
  if (!isRelation) {
@@ -4,7 +4,7 @@ const { isArray } = require('lodash/fp');
4
4
 
5
5
  module.exports =
6
6
  (restrictedFields = null) =>
7
- ({ key, path }, { remove }) => {
7
+ ({ key, path: { attribute: path } }, { remove }) => {
8
8
  // Remove all fields
9
9
  if (restrictedFields === null) {
10
10
  remove(key);
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ const { isNil, pick } = require('lodash/fp');
4
+
5
+ module.exports = () => {
6
+ const state = {
7
+ parsers: [],
8
+ interceptors: [],
9
+ ignore: [],
10
+ handlers: {
11
+ attributes: [],
12
+ common: [],
13
+ },
14
+ };
15
+
16
+ const traverse = async (visitor, options, data) => {
17
+ const { path = { raw: null, attribute: null }, schema } = options ?? {};
18
+
19
+ // interceptors
20
+ for (const { predicate, handler } of state.interceptors) {
21
+ if (predicate(data)) {
22
+ return handler(visitor, options, data, { recurse: traverse });
23
+ }
24
+ }
25
+
26
+ // parsers
27
+ const parser = state.parsers.find((parser) => parser.predicate(data))?.parser;
28
+ const utils = parser?.(data);
29
+
30
+ // Return the data untouched if we don't know how to traverse it
31
+ if (!utils) {
32
+ return data;
33
+ }
34
+
35
+ // main loop
36
+ let out = utils.transform(data);
37
+ const keys = utils.keys(out);
38
+
39
+ for (const key of keys) {
40
+ const attribute =
41
+ schema?.attributes?.[key] ??
42
+ // FIX: Needed to not break existing behavior on the API.
43
+ // It looks for the attribute in the DB metadata when the key is in snake_case
44
+ schema?.attributes?.[strapi.db.metadata.get(schema?.uid).columnToAttribute[key]];
45
+
46
+ const newPath = { ...path };
47
+
48
+ newPath.raw = isNil(path.raw) ? key : `${path.raw}.${key}`;
49
+
50
+ if (!isNil(attribute)) {
51
+ newPath.attribute = isNil(path.attribute) ? key : `${path.attribute}.${key}`;
52
+ }
53
+
54
+ // visitors
55
+
56
+ const visitorOptions = {
57
+ key,
58
+ value: utils.get(key, out),
59
+ attribute,
60
+ schema,
61
+ path: newPath,
62
+ data: out,
63
+ };
64
+
65
+ const transformUtils = {
66
+ remove(key) {
67
+ out = utils.remove(key, out);
68
+ },
69
+ set(key, value) {
70
+ out = utils.set(key, value, out);
71
+ },
72
+ recurse: traverse,
73
+ };
74
+
75
+ await visitor(visitorOptions, pick(['remove', 'set'], transformUtils));
76
+
77
+ const value = utils.get(key, out);
78
+
79
+ const createContext = () => ({
80
+ key,
81
+ value,
82
+ attribute,
83
+ schema,
84
+ path: newPath,
85
+ data: out,
86
+ visitor,
87
+ });
88
+
89
+ // ignore
90
+ const ignoreCtx = createContext();
91
+ const shouldIgnore = state.ignore.some((predicate) => predicate(ignoreCtx));
92
+
93
+ if (shouldIgnore) {
94
+ continue;
95
+ }
96
+
97
+ // handlers
98
+ const handlers = [...state.handlers.common, ...state.handlers.attributes];
99
+
100
+ for await (const handler of handlers) {
101
+ const ctx = createContext();
102
+ const pass = await handler.predicate(ctx);
103
+
104
+ if (pass) {
105
+ await handler.handler(ctx, pick(['recurse', 'set'], transformUtils));
106
+ }
107
+ }
108
+ }
109
+
110
+ return out;
111
+ };
112
+
113
+ return {
114
+ traverse,
115
+
116
+ intercept(predicate, handler) {
117
+ state.interceptors.push({ predicate, handler });
118
+ return this;
119
+ },
120
+
121
+ parse(predicate, parser) {
122
+ state.parsers.push({ predicate, parser });
123
+ return this;
124
+ },
125
+
126
+ ignore(predicate) {
127
+ state.ignore.push(predicate);
128
+ return this;
129
+ },
130
+
131
+ on(predicate, handler) {
132
+ state.handlers.common.push({ predicate, handler });
133
+ return this;
134
+ },
135
+
136
+ onAttribute(predicate, handler) {
137
+ state.handlers.attributes.push({ predicate, handler });
138
+ return this;
139
+ },
140
+
141
+ onRelation(handler) {
142
+ return this.onAttribute(({ attribute }) => attribute?.type === 'relation', handler);
143
+ },
144
+
145
+ onMedia(handler) {
146
+ return this.onAttribute(({ attribute }) => attribute?.type === 'media', handler);
147
+ },
148
+
149
+ onComponent(handler) {
150
+ return this.onAttribute(({ attribute }) => attribute?.type === 'component', handler);
151
+ },
152
+
153
+ onDynamicZone(handler) {
154
+ return this.onAttribute(({ attribute }) => attribute?.type === 'dynamiczone', handler);
155
+ },
156
+ };
157
+ };
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ const factory = require('./factory');
4
+
5
+ const traverseQueryFilters = require('./query-filters');
6
+ const traverseQuerySort = require('./query-sort');
7
+ const traverseQueryPopulate = require('./query-populate');
8
+ const traverseQueryFields = require('./query-fields');
9
+
10
+ module.exports = {
11
+ factory,
12
+ traverseQueryFilters,
13
+ traverseQuerySort,
14
+ traverseQueryPopulate,
15
+ traverseQueryFields,
16
+ };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ const { curry, isArray, isString, eq, trim, constant } = require('lodash/fp');
4
+
5
+ const traverseFactory = require('./factory');
6
+
7
+ const isStringArray = (value) => isArray(value) && value.every(isString);
8
+
9
+ const fields = traverseFactory()
10
+ // Interecept array of strings
11
+ .intercept(isStringArray, async (visitor, options, fields, { recurse }) => {
12
+ return Promise.all(fields.map((field) => recurse(visitor, options, field)));
13
+ })
14
+ // Return wildcards as is
15
+ .intercept(eq('*'), constant('*'))
16
+ // Parse string values
17
+ // Since we're parsing strings only, each value should be an attribute name (and it's value, undefined),
18
+ // thus it shouldn't be possible to set a new value, and get should return the whole data if key === data
19
+ .parse(isString, () => ({
20
+ transform: trim,
21
+
22
+ remove(key, data) {
23
+ return data === key ? undefined : data;
24
+ },
25
+
26
+ set(_key, _value, data) {
27
+ return data;
28
+ },
29
+
30
+ keys(data) {
31
+ return [data];
32
+ },
33
+
34
+ get(key, data) {
35
+ return key === data ? data : undefined;
36
+ },
37
+ }));
38
+
39
+ module.exports = curry(fields.traverse);
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ const { curry, isObject, isEmpty, isArray, isNil, cloneDeep } = require('lodash/fp');
4
+
5
+ const traverseFactory = require('./factory');
6
+
7
+ const filters = traverseFactory()
8
+ .intercept(
9
+ // Intercept filters arrays and apply the traversal to each one individually
10
+ isArray,
11
+ async (visitor, options, filters, { recurse }) => {
12
+ return Promise.all(
13
+ filters.map((filter, i) => {
14
+ // In filters, only operators such as $and, $in, $notIn or $or and implicit operators like [...]
15
+ // can have a value array, thus we can update the raw path but not the attribute one
16
+ const newPath = { ...options.path, raw: `${options.path.raw}[${i}]` };
17
+
18
+ return recurse(visitor, { ...options, path: newPath }, filter);
19
+ })
20
+ // todo: move that to the visitors
21
+ ).then((res) => res.filter((val) => !(isObject(val) && isEmpty(val))));
22
+ }
23
+ )
24
+ .intercept(
25
+ // Ignore non object filters and return the value as-is
26
+ (filters) => !isObject(filters),
27
+ (_, __, filters) => {
28
+ return filters;
29
+ }
30
+ )
31
+ // Parse object values
32
+ .parse(
33
+ (value) => typeof value === 'object',
34
+ () => ({
35
+ transform: cloneDeep,
36
+
37
+ remove(key, data) {
38
+ const { [key]: ignored, ...rest } = data;
39
+
40
+ return rest;
41
+ },
42
+
43
+ set(key, value, data) {
44
+ return { ...data, [key]: value };
45
+ },
46
+
47
+ keys(data) {
48
+ return Object.keys(data);
49
+ },
50
+
51
+ get(key, data) {
52
+ return data[key];
53
+ },
54
+ })
55
+ )
56
+ // Ignore null or undefined values
57
+ .ignore(({ value }) => isNil(value))
58
+ // Recursion on operators (non attributes)
59
+ .on(
60
+ ({ attribute }) => isNil(attribute),
61
+ async ({ key, visitor, path, value, schema }, { set, recurse }) => {
62
+ set(key, await recurse(visitor, { schema, path }, value));
63
+ }
64
+ )
65
+ // Handle relation recursion
66
+ .onRelation(async ({ key, attribute, visitor, path, value }, { set, recurse }) => {
67
+ const isMorphRelation = attribute.relation.toLowerCase().startsWith('morph');
68
+
69
+ if (isMorphRelation) {
70
+ return;
71
+ }
72
+
73
+ const targetSchemaUID = attribute.target;
74
+ const targetSchema = strapi.getModel(targetSchemaUID);
75
+
76
+ const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
77
+
78
+ set(key, newValue);
79
+ })
80
+ .onComponent(async ({ key, attribute, visitor, path, value }, { set, recurse }) => {
81
+ const targetSchema = strapi.getModel(attribute.component);
82
+
83
+ const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
84
+
85
+ set(key, newValue);
86
+ })
87
+ // Handle media recursion
88
+ .onMedia(async ({ key, visitor, path, value }, { set, recurse }) => {
89
+ const targetSchemaUID = 'plugin::upload.file';
90
+ const targetSchema = strapi.getModel(targetSchemaUID);
91
+
92
+ const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
93
+
94
+ set(key, newValue);
95
+ });
96
+
97
+ module.exports = curry(filters.traverse);
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ curry,
5
+ isString,
6
+ isArray,
7
+ eq,
8
+ constant,
9
+ split,
10
+ isObject,
11
+ trim,
12
+ isNil,
13
+ cloneDeep,
14
+ join,
15
+ first,
16
+ } = require('lodash/fp');
17
+
18
+ const traverseFactory = require('./factory');
19
+
20
+ const isKeyword =
21
+ (keyword) =>
22
+ ({ key, attribute }) => {
23
+ return !attribute && keyword === key;
24
+ };
25
+ const isStringArray = (value) => isArray(value) && value.every(isString);
26
+
27
+ const populate = traverseFactory()
28
+ // Array of strings ['foo', 'foo.bar'] => map(recurse), then filter out empty items
29
+ .intercept(isStringArray, async (visitor, options, populate, { recurse }) => {
30
+ return Promise.all(populate.map((nestedPopulate) => recurse(visitor, options, nestedPopulate)));
31
+ })
32
+ // Return wildcards as is
33
+ .intercept(eq('*'), constant('*'))
34
+ // Parse string values
35
+ .parse(isString, () => {
36
+ const tokenize = split('.');
37
+ const recompose = join('.');
38
+
39
+ return {
40
+ transform: trim,
41
+
42
+ remove(key, data) {
43
+ const [root] = tokenize(data);
44
+
45
+ return root === key ? undefined : data;
46
+ },
47
+
48
+ set(key, value, data) {
49
+ const [root] = tokenize(data);
50
+
51
+ if (root !== key) {
52
+ return data;
53
+ }
54
+
55
+ return isNil(value) ? root : `${root}.${value}`;
56
+ },
57
+
58
+ keys(data) {
59
+ return [first(tokenize(data))];
60
+ },
61
+
62
+ get(key, data) {
63
+ const [root, ...rest] = tokenize(data);
64
+
65
+ return key === root ? recompose(rest) : undefined;
66
+ },
67
+ };
68
+ })
69
+ // Parse object values
70
+ .parse(isObject, () => ({
71
+ transform: cloneDeep,
72
+
73
+ remove(key, data) {
74
+ const { [key]: ignored, ...rest } = data;
75
+
76
+ return rest;
77
+ },
78
+
79
+ set(key, value, data) {
80
+ return { ...data, [key]: value };
81
+ },
82
+
83
+ keys(data) {
84
+ return Object.keys(data);
85
+ },
86
+
87
+ get(key, data) {
88
+ return data[key];
89
+ },
90
+ }))
91
+ .ignore(({ key, attribute }) => {
92
+ return ['sort', 'filters', 'fields'].includes(key) && !attribute;
93
+ })
94
+ .on(
95
+ // Handle recursion on populate."populate"
96
+ isKeyword('populate'),
97
+ async ({ key, visitor, path, value, schema }, { set, recurse }) => {
98
+ const newValue = await recurse(visitor, { schema, path }, value);
99
+
100
+ set(key, newValue);
101
+ }
102
+ )
103
+ .on(isKeyword('on'), async ({ key, visitor, path, value }, { set, recurse }) => {
104
+ const newOn = {};
105
+
106
+ for (const [uid, subPopulate] of Object.entries(value)) {
107
+ const model = strapi.getModel(uid);
108
+ const newPath = { ...path, raw: `${path.raw}[${uid}]` };
109
+
110
+ const newSubPopulate = await recurse(visitor, { schema: model, path: newPath }, subPopulate);
111
+
112
+ newOn[uid] = newSubPopulate;
113
+ }
114
+
115
+ set(key, newOn);
116
+ })
117
+ // Handle populate on relation
118
+ .onRelation(async ({ key, value, attribute, visitor, path, schema }, { set, recurse }) => {
119
+ const isMorphRelation = attribute.relation.toLowerCase().startsWith('morph');
120
+
121
+ if (isMorphRelation) {
122
+ // Don't traverse values that cannot be parsed
123
+ if (!isObject(value) || !isObject(value?.on)) {
124
+ return;
125
+ }
126
+
127
+ // If there is a populate fragment defined, traverse it
128
+ const newValue = await recurse(visitor, { schema, path }, { on: value.on });
129
+
130
+ set(key, { on: newValue });
131
+ }
132
+
133
+ const targetSchemaUID = attribute.target;
134
+ const targetSchema = strapi.getModel(targetSchemaUID);
135
+
136
+ const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
137
+
138
+ set(key, newValue);
139
+ })
140
+ // Handle populate on media
141
+ .onMedia(async ({ key, path, visitor, value }, { recurse, set }) => {
142
+ const targetSchemaUID = 'plugin::upload.file';
143
+ const targetSchema = strapi.getModel(targetSchemaUID);
144
+
145
+ const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
146
+
147
+ set(key, newValue);
148
+ })
149
+ // Handle populate on components
150
+ .onComponent(async ({ key, value, visitor, path, attribute }, { recurse, set }) => {
151
+ const targetSchema = strapi.getModel(attribute.component);
152
+
153
+ const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
154
+
155
+ set(key, newValue);
156
+ })
157
+ // Handle populate on dynamic zones
158
+ .onDynamicZone(async ({ key, value, attribute, schema, visitor, path }, { set, recurse }) => {
159
+ if (isObject(value)) {
160
+ const { components } = attribute;
161
+ const { on, ...properties } = value;
162
+
163
+ const newValue = {};
164
+
165
+ // Handle legacy DZ params
166
+ let newProperties = properties;
167
+
168
+ for (const componentUID of components) {
169
+ const componentSchema = strapi.getModel(componentUID);
170
+ newProperties = await recurse(visitor, { schema: componentSchema, path }, newProperties);
171
+ }
172
+
173
+ Object.assign(newValue, newProperties);
174
+
175
+ // Handle new morph fragment syntax
176
+ if (on) {
177
+ const newOn = await recurse(visitor, { schema, path }, { on });
178
+
179
+ // Recompose both syntaxes
180
+ Object.assign(newValue, newOn);
181
+ }
182
+
183
+ set(key, newValue);
184
+ } else {
185
+ const newValue = await recurse(visitor, { schema, path }, value);
186
+
187
+ set(key, newValue);
188
+ }
189
+ });
190
+
191
+ module.exports = curry(populate.traverse);
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ curry,
5
+ isString,
6
+ isObject,
7
+ map,
8
+ trim,
9
+ split,
10
+ isEmpty,
11
+ flatten,
12
+ pipe,
13
+ isNil,
14
+ first,
15
+ cloneDeep,
16
+ } = require('lodash/fp');
17
+
18
+ const traverseFactory = require('./factory');
19
+
20
+ const ORDERS = { asc: 'asc', desc: 'desc' };
21
+ const ORDER_VALUES = Object.values(ORDERS);
22
+
23
+ const isSortOrder = (value) => ORDER_VALUES.includes(value.toLowerCase());
24
+ const isStringArray = (value) => Array.isArray(value) && value.every(isString);
25
+ const isObjectArray = (value) => Array.isArray(value) && value.every(isObject);
26
+ const isNestedSorts = (value) => isString(value) && value.split(',').length > 1;
27
+
28
+ const sort = traverseFactory()
29
+ .intercept(
30
+ // String with chained sorts (foo,bar,foobar) => split, map(recurse), then recompose
31
+ isNestedSorts,
32
+ async (visitor, options, sort, { recurse }) => {
33
+ return Promise.all(
34
+ sort
35
+ .split(',')
36
+ .map(trim)
37
+ .map((nestedSort) => recurse(visitor, options, nestedSort))
38
+ ).then((res) => res.filter((part) => !isEmpty(part)).join(','));
39
+ }
40
+ )
41
+ .intercept(
42
+ // Array of strings ['foo', 'foo,bar'] => map(recurse), then filter out empty items
43
+ isStringArray,
44
+ async (visitor, options, sort, { recurse }) => {
45
+ return Promise.all(sort.map((nestedSort) => recurse(visitor, options, nestedSort))).then(
46
+ (res) => res.filter((nestedSort) => !isEmpty(nestedSort))
47
+ );
48
+ }
49
+ )
50
+ .intercept(
51
+ // Array of objects [{ foo: 'asc' }, { bar: 'desc', baz: 'asc' }] => map(recurse), then filter out empty items
52
+ isObjectArray,
53
+ async (visitor, options, sort, { recurse }) => {
54
+ return Promise.all(sort.map((nestedSort) => recurse(visitor, options, nestedSort))).then(
55
+ (res) => res.filter((nestedSort) => !isEmpty(nestedSort))
56
+ );
57
+ }
58
+ )
59
+ // Parse string values
60
+ .parse(
61
+ (sort) => typeof sort === 'string',
62
+ () => {
63
+ const tokenize = pipe(split('.'), map(split(':')), flatten);
64
+ const recompose = (parts) => {
65
+ if (parts.length === 0) {
66
+ return undefined;
67
+ }
68
+
69
+ return parts.reduce((acc, part) => {
70
+ if (isEmpty(part)) {
71
+ return acc;
72
+ }
73
+
74
+ if (acc === '') {
75
+ return part;
76
+ }
77
+
78
+ return isSortOrder(part) ? `${acc}:${part}` : `${acc}.${part}`;
79
+ }, '');
80
+ };
81
+
82
+ return {
83
+ transform: trim,
84
+
85
+ remove(key, data) {
86
+ const [root] = tokenize(data);
87
+
88
+ return root === key ? undefined : data;
89
+ },
90
+
91
+ set(key, value, data) {
92
+ const [root] = tokenize(data);
93
+
94
+ if (root !== key) {
95
+ return data;
96
+ }
97
+
98
+ return isNil(value) ? root : `${root}.${value}`;
99
+ },
100
+
101
+ keys(data) {
102
+ return [first(tokenize(data))];
103
+ },
104
+
105
+ get(key, data) {
106
+ const [root, ...rest] = tokenize(data);
107
+
108
+ return key === root ? recompose(rest) : undefined;
109
+ },
110
+ };
111
+ }
112
+ )
113
+ // Parse object values
114
+ .parse(
115
+ (value) => typeof value === 'object',
116
+ () => ({
117
+ transform: cloneDeep,
118
+
119
+ remove(key, data) {
120
+ const { [key]: ignored, ...rest } = data;
121
+
122
+ return rest;
123
+ },
124
+
125
+ set(key, value, data) {
126
+ return { ...data, [key]: value };
127
+ },
128
+
129
+ keys(data) {
130
+ return Object.keys(data);
131
+ },
132
+
133
+ get(key, data) {
134
+ return data[key];
135
+ },
136
+ })
137
+ )
138
+ // Handle deep sort on relation
139
+ .onRelation(async ({ key, value, attribute, visitor, path }, { set, recurse }) => {
140
+ const isMorphRelation = attribute.relation.toLowerCase().startsWith('morph');
141
+
142
+ if (isMorphRelation) {
143
+ return;
144
+ }
145
+
146
+ const targetSchemaUID = attribute.target;
147
+ const targetSchema = strapi.getModel(targetSchemaUID);
148
+
149
+ const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
150
+
151
+ set(key, newValue);
152
+ })
153
+ // Handle deep sort on media
154
+ .onMedia(async ({ key, path, visitor, value }, { recurse, set }) => {
155
+ const targetSchemaUID = 'plugin::upload.file';
156
+ const targetSchema = strapi.getModel(targetSchemaUID);
157
+
158
+ const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
159
+
160
+ set(key, newValue);
161
+ })
162
+ // Handle deep sort on components
163
+ .onComponent(async ({ key, value, visitor, path, attribute }, { recurse, set }) => {
164
+ const targetSchema = strapi.getModel(attribute.component);
165
+
166
+ const newValue = await recurse(visitor, { schema: targetSchema, path }, value);
167
+
168
+ set(key, newValue);
169
+ });
170
+
171
+ module.exports = curry(sort.traverse);
@@ -3,7 +3,7 @@
3
3
  const { cloneDeep, isObject, isArray, isNil, curry } = require('lodash/fp');
4
4
 
5
5
  const traverseEntity = async (visitor, options, entity) => {
6
- const { path = null, schema } = options;
6
+ const { path = { raw: null, attribute: null }, schema } = options;
7
7
 
8
8
  // End recursion
9
9
  if (!isObject(entity) || isNil(schema)) {
@@ -22,7 +22,13 @@ const traverseEntity = async (visitor, options, entity) => {
22
22
  continue;
23
23
  }
24
24
 
25
- const newPath = path ? `${path}.${key}` : key;
25
+ const newPath = { ...path };
26
+
27
+ newPath.raw = isNil(path.raw) ? key : `${path.raw}.${key}`;
28
+
29
+ if (!isNil(attribute)) {
30
+ newPath.attribute = isNil(path.attribute) ? key : `${path.attribute}.${key}`;
31
+ }
26
32
 
27
33
  // Visit the current attribute
28
34
  const visitorOptions = { data: copy, schema, key, value: copy[key], attribute, path: newPath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strapi/utils",
3
- "version": "4.9.0-alpha.0",
3
+ "version": "4.9.0-beta.1",
4
4
  "description": "Shared utilities for the Strapi packages",
5
5
  "keywords": [
6
6
  "strapi",
@@ -46,5 +46,5 @@
46
46
  "node": ">=14.19.1 <=18.x.x",
47
47
  "npm": ">=6.0.0"
48
48
  },
49
- "gitHead": "35f783d0dc07db101e7e62cb4d682f751551f452"
49
+ "gitHead": "ff37d666d0634fc84827e3d6419d916618275572"
50
50
  }