@strapi/utils 4.0.0-beta.2 → 4.0.0-beta.20

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.
package/lib/config.js CHANGED
@@ -3,7 +3,10 @@
3
3
  const _ = require('lodash');
4
4
  const { getCommonPath } = require('./string-formatting');
5
5
 
6
- const getConfigUrls = (serverConfig, forAdminBuild = false) => {
6
+ const getConfigUrls = (config, forAdminBuild = false) => {
7
+ const serverConfig = config.get('server');
8
+ const adminConfig = config.get('admin');
9
+
7
10
  // Defines serverUrl value
8
11
  let serverUrl = _.get(serverConfig, 'url', '');
9
12
  serverUrl = _.trim(serverUrl, '/ ');
@@ -23,7 +26,7 @@ const getConfigUrls = (serverConfig, forAdminBuild = false) => {
23
26
  }
24
27
 
25
28
  // Defines adminUrl value
26
- let adminUrl = _.get(serverConfig, 'admin.url', '/admin');
29
+ let adminUrl = _.get(adminConfig, 'url', '/admin');
27
30
  adminUrl = _.trim(adminUrl, '/ ');
28
31
  if (typeof adminUrl !== 'string') {
29
32
  throw new Error('Invalid admin url config. Make sure the url is a non-empty string.');
@@ -60,7 +63,7 @@ const getConfigUrls = (serverConfig, forAdminBuild = false) => {
60
63
  };
61
64
 
62
65
  const getAbsoluteUrl = adminOrServer => (config, forAdminBuild = false) => {
63
- const { serverUrl, adminUrl } = getConfigUrls(config.get('server'), forAdminBuild);
66
+ const { serverUrl, adminUrl } = getConfigUrls(config, forAdminBuild);
64
67
  let url = adminOrServer === 'server' ? serverUrl : adminUrl;
65
68
 
66
69
  if (url.startsWith('http')) {
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const _ = require('lodash');
4
+ const { has } = require('lodash/fp');
4
5
 
5
6
  const SINGLE_TYPE = 'singleType';
6
7
  const COLLECTION_TYPE = 'collectionType';
@@ -31,8 +32,18 @@ const constants = {
31
32
  COLLECTION_TYPE,
32
33
  };
33
34
 
34
- const getTimestamps = () => {
35
- return [CREATED_AT_ATTRIBUTE, UPDATED_AT_ATTRIBUTE];
35
+ const getTimestamps = model => {
36
+ const attributes = [];
37
+
38
+ if (has(CREATED_AT_ATTRIBUTE, model.attributes)) {
39
+ attributes.push(CREATED_AT_ATTRIBUTE);
40
+ }
41
+
42
+ if (has(UPDATED_AT_ATTRIBUTE, model.attributes)) {
43
+ attributes.push(UPDATED_AT_ATTRIBUTE);
44
+ }
45
+
46
+ return attributes;
36
47
  };
37
48
 
38
49
  const getNonWritableAttributes = (model = {}) => {
@@ -42,7 +53,7 @@ const getNonWritableAttributes = (model = {}) => {
42
53
  []
43
54
  );
44
55
 
45
- return _.uniq([ID_ATTRIBUTE, ...getTimestamps(), ...nonWritableAttributes]);
56
+ return _.uniq([ID_ATTRIBUTE, ...getTimestamps(model), ...nonWritableAttributes]);
46
57
  };
47
58
 
48
59
  const getWritableAttributes = (model = {}) => {
@@ -60,7 +71,7 @@ const getNonVisibleAttributes = model => {
60
71
  []
61
72
  );
62
73
 
63
- return _.uniq([ID_ATTRIBUTE, ...getTimestamps(), ...nonVisibleAttributes]);
74
+ return _.uniq([ID_ATTRIBUTE, ...getTimestamps(model), ...nonVisibleAttributes]);
64
75
  };
65
76
 
66
77
  const getVisibleAttributes = model => {
@@ -137,6 +148,7 @@ module.exports = {
137
148
  isWritableAttribute,
138
149
  getNonVisibleAttributes,
139
150
  getVisibleAttributes,
151
+ getTimestamps,
140
152
  isVisibleAttribute,
141
153
  hasDraftAndPublish,
142
154
  isDraft,
@@ -2,13 +2,14 @@
2
2
 
3
3
  /**
4
4
  * Converts the standard Strapi REST query params to a more usable format for querying
5
- * You can read more here: https://strapi.io/documentation/developer-docs/latest/developer-resources/content-api/content-api.html#filters
5
+ * You can read more here: https://docs.strapi.io/developer-docs/latest/developer-resources/database-apis-reference/rest-api.html#filters
6
6
  */
7
-
7
+ const { has } = require('lodash/fp');
8
8
  const _ = require('lodash');
9
9
  const parseType = require('./parse-type');
10
+ const contentTypesUtils = require('./content-types');
10
11
 
11
- const QUERY_OPERATORS = ['_where', '_or', '_and'];
12
+ const { PUBLISHED_AT_ATTRIBUTE } = contentTypesUtils.constants;
12
13
 
13
14
  class InvalidOrderError extends Error {
14
15
  constructor() {
@@ -132,13 +133,15 @@ const convertPopulateQueryParams = (populate, depth = 0) => {
132
133
 
133
134
  if (Array.isArray(populate)) {
134
135
  // map convert
135
- return populate.flatMap(value => {
136
- if (typeof value !== 'string') {
137
- throw new InvalidPopulateError();
138
- }
136
+ return _.uniq(
137
+ populate.flatMap(value => {
138
+ if (typeof value !== 'string') {
139
+ throw new InvalidPopulateError();
140
+ }
139
141
 
140
- return value.split(',').map(value => _.trim(value));
141
- });
142
+ return value.split(',').map(value => _.trim(value));
143
+ })
144
+ );
142
145
  }
143
146
 
144
147
  if (_.isPlainObject(populate)) {
@@ -146,6 +149,7 @@ const convertPopulateQueryParams = (populate, depth = 0) => {
146
149
  for (const key in populate) {
147
150
  transformedPopulate[key] = convertNestedPopulate(populate[key]);
148
151
  }
152
+
149
153
  return transformedPopulate;
150
154
  }
151
155
 
@@ -212,9 +216,31 @@ const convertFieldsQueryParams = (fields, depth = 0) => {
212
216
  throw new Error('Invalid fields parameter. Expected a string or an array of strings');
213
217
  };
214
218
 
215
- // NOTE: We could validate the parameters are on existing / non private attributes
216
219
  const convertFiltersQueryParams = filters => filters;
217
220
 
221
+ const convertPublicationStateParams = (type, params = {}, query = {}) => {
222
+ if (!type) {
223
+ return;
224
+ }
225
+
226
+ const { publicationState } = params;
227
+
228
+ if (!_.isNil(publicationState)) {
229
+ if (!contentTypesUtils.constants.DP_PUB_STATES.includes(publicationState)) {
230
+ throw new Error(
231
+ `Invalid publicationState. Expected one of 'preview','live' received: ${publicationState}.`
232
+ );
233
+ }
234
+
235
+ // NOTE: this is the query layer filters not the entity service filters
236
+ query.filters = ({ meta }) => {
237
+ if (publicationState === 'live' && has(PUBLISHED_AT_ATTRIBUTE, meta.attributes)) {
238
+ return { [PUBLISHED_AT_ATTRIBUTE]: { $notNull: true } };
239
+ }
240
+ };
241
+ }
242
+ };
243
+
218
244
  module.exports = {
219
245
  convertSortQueryParams,
220
246
  convertStartQueryParams,
@@ -222,5 +248,5 @@ module.exports = {
222
248
  convertPopulateQueryParams,
223
249
  convertFiltersQueryParams,
224
250
  convertFieldsQueryParams,
225
- QUERY_OPERATORS,
251
+ convertPublicationStateParams,
226
252
  };
package/lib/errors.js ADDED
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ const { HttpError } = require('http-errors');
4
+ const { formatYupErrors } = require('./format-yup-error');
5
+
6
+ /* ApplicationError */
7
+ class ApplicationError extends Error {
8
+ constructor(message, details = {}) {
9
+ super();
10
+ this.name = 'ApplicationError';
11
+ this.message = message || 'An application error occured';
12
+ this.details = details;
13
+ }
14
+ }
15
+
16
+ class ValidationError extends ApplicationError {
17
+ constructor(message, details) {
18
+ super(message, details);
19
+ this.name = 'ValidationError';
20
+ }
21
+ }
22
+
23
+ class YupValidationError extends ValidationError {
24
+ constructor(yupError, message) {
25
+ super();
26
+ const { errors, message: yupMessage } = formatYupErrors(yupError);
27
+ this.message = message || yupMessage;
28
+ this.details = { errors };
29
+ }
30
+ }
31
+
32
+ class PaginationError extends ApplicationError {
33
+ constructor(message, details) {
34
+ super(message, details);
35
+ this.name = 'PaginationError';
36
+ this.message = message || 'Invalid pagination';
37
+ }
38
+ }
39
+
40
+ class NotFoundError extends ApplicationError {
41
+ constructor(message, details) {
42
+ super(message, details);
43
+ this.name = 'NotFoundError';
44
+ this.message = message || 'Entity not found';
45
+ }
46
+ }
47
+
48
+ class ForbiddenError extends ApplicationError {
49
+ constructor(message, details) {
50
+ super(message, details);
51
+ this.name = 'ForbiddenError';
52
+ this.message = message || 'Forbidden access';
53
+ }
54
+ }
55
+
56
+ class PayloadTooLargeError extends ApplicationError {
57
+ constructor(message, details) {
58
+ super(message, details);
59
+ this.name = 'PayloadTooLargeError';
60
+ this.message = message || 'Entity too large';
61
+ }
62
+ }
63
+
64
+ class UnauthorizedError extends ApplicationError {
65
+ constructor(message, details) {
66
+ super(message, details);
67
+ this.name = 'UnauthorizedError';
68
+ this.message = message || 'Unauthorized';
69
+ }
70
+ }
71
+
72
+ module.exports = {
73
+ HttpError,
74
+ ApplicationError,
75
+ ValidationError,
76
+ YupValidationError,
77
+ PaginationError,
78
+ NotFoundError,
79
+ ForbiddenError,
80
+ PayloadTooLargeError,
81
+ UnauthorizedError,
82
+ };
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ const { isEmpty, toPath } = require('lodash/fp');
4
+
5
+ const formatYupInnerError = yupError => ({
6
+ path: toPath(yupError.path),
7
+ message: yupError.message,
8
+ name: yupError.name,
9
+ });
10
+
11
+ const formatYupErrors = yupError => ({
12
+ errors: isEmpty(yupError.inner)
13
+ ? [formatYupInnerError(yupError)]
14
+ : yupError.inner.map(formatYupInnerError),
15
+ message: yupError.message,
16
+ });
17
+
18
+ module.exports = {
19
+ formatYupErrors,
20
+ };
package/lib/hooks.js CHANGED
@@ -82,7 +82,7 @@ const createAsyncSeriesWaterfallHook = () => ({
82
82
  const createAsyncParallelHook = () => ({
83
83
  ...createHook(),
84
84
 
85
- call(context) {
85
+ async call(context) {
86
86
  const promises = this.handlers.map(handler => handler(cloneDeep(context)));
87
87
 
88
88
  return Promise.all(promises);
package/lib/index.js CHANGED
@@ -4,13 +4,12 @@
4
4
  * Export shared utilities
5
5
  */
6
6
  const { buildQuery, hasDeepFilters } = require('./build-query');
7
- const { QUERY_OPERATORS } = require('./convert-query-params');
8
7
  const parseMultipartData = require('./parse-multipart');
9
- const sanitizeEntity = require('./sanitize-entity');
10
8
  const parseType = require('./parse-type');
11
9
  const policy = require('./policy');
12
10
  const templateConfiguration = require('./template-configuration');
13
- const { yup, formatYupErrors } = require('./validators');
11
+ const { yup, handleYupError, validateYupSchema, validateYupSchemaSync } = require('./validators');
12
+ const errors = require('./errors');
14
13
  const {
15
14
  nameToSlug,
16
15
  nameToCollectionName,
@@ -32,17 +31,20 @@ const setCreatorFields = require('./set-creator-fields');
32
31
  const hooks = require('./hooks');
33
32
  const providerFactory = require('./provider-factory');
34
33
  const pagination = require('./pagination');
34
+ const sanitize = require('./sanitize');
35
+ const traverseEntity = require('./traverse-entity');
36
+ const pipeAsync = require('./pipe-async');
35
37
 
36
38
  module.exports = {
37
39
  yup,
38
- formatYupErrors,
40
+ handleYupError,
39
41
  policy,
40
42
  templateConfiguration,
41
- QUERY_OPERATORS,
42
43
  buildQuery,
43
44
  hasDeepFilters,
44
45
  parseMultipartData,
45
- sanitizeEntity,
46
+ sanitize,
47
+ traverseEntity,
46
48
  parseType,
47
49
  nameToSlug,
48
50
  nameToCollectionName,
@@ -65,4 +67,8 @@ module.exports = {
65
67
  hooks,
66
68
  providerFactory,
67
69
  pagination,
70
+ pipeAsync,
71
+ errors,
72
+ validateYupSchema,
73
+ validateYupSchemaSync,
68
74
  };
package/lib/pagination.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { merge, pipe, omit, isNil } = require('lodash/fp');
4
+ const { PaginationError } = require('./errors');
4
5
 
5
6
  const STRAPI_DEFAULTS = {
6
7
  offset: {
@@ -52,7 +53,7 @@ const withDefaultPagination = (args, { defaults = {}, maxLimit = -1 } = {}) => {
52
53
 
53
54
  // If there is page & offset pagination attributes, throw an error
54
55
  if (usePagePagination && useOffsetPagination) {
55
- throw new Error('Cannot use both page & offset pagination in the same query');
56
+ throw new PaginationError('Cannot use both page & offset pagination in the same query');
56
57
  }
57
58
 
58
59
  const pagination = {};
@@ -10,7 +10,7 @@ module.exports = ctx => {
10
10
  const { body = {}, files = {} } = ctx.request;
11
11
 
12
12
  if (!body.data) {
13
- throw strapi.errors.badRequest(
13
+ return ctx.badRequest(
14
14
  `When using multipart/form-data you need to provide your data in a JSON 'data' field.`
15
15
  );
16
16
  }
@@ -19,14 +19,14 @@ module.exports = ctx => {
19
19
  try {
20
20
  data = JSON.parse(body.data);
21
21
  } catch (error) {
22
- throw strapi.errors.badRequest(`Invalid 'data' field. 'data' should be a valid JSON.`);
22
+ return ctx.badRequest(`Invalid 'data' field. 'data' should be a valid JSON.`);
23
23
  }
24
24
 
25
25
  const filesToUpload = Object.keys(files).reduce((acc, key) => {
26
26
  const fullPath = _.toPath(key);
27
27
 
28
28
  if (fullPath.length <= 1 || fullPath[0] !== 'files') {
29
- throw strapi.errors.badRequest(
29
+ return ctx.badRequest(
30
30
  `When using multipart/form-data you need to provide your files by prefixing them with the 'files'.
31
31
  For example, when a media file is named "avatar", make sure the form key name is "files.avatar"`
32
32
  );
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ module.exports = (...methods) => async data => {
4
+ let res = data;
5
+
6
+ for (const method of methods) {
7
+ res = await method(res);
8
+ }
9
+
10
+ return res;
11
+ };
package/lib/policy.js CHANGED
@@ -9,36 +9,22 @@ const { eq } = require('lodash/fp');
9
9
  const PLUGIN_PREFIX = 'plugin::';
10
10
  const API_PREFIX = 'api::';
11
11
 
12
- const createPolicy = (policyName, args) => ({ policyName, args });
13
-
14
- const resolveHandler = policy => {
15
- return _.has('handler', policy) ? policy.handler : policy;
16
- };
17
-
18
12
  const parsePolicy = policy => {
19
13
  if (typeof policy === 'string') {
20
- return createPolicy(policy);
14
+ return { policyName: policy, config: {} };
21
15
  }
22
16
 
23
- const { name, options = {} } = policy;
24
- return createPolicy(name, options);
25
- };
26
-
27
- const resolvePolicy = policyName => {
28
- const policy = strapi.policy(policyName);
29
-
30
- return resolveHandler(policy);
17
+ const { name, config } = policy;
18
+ return { policyName: name, config };
31
19
  };
32
20
 
33
21
  const searchLocalPolicy = (policyName, { pluginName, apiName }) => {
34
22
  if (pluginName) {
35
- const policy = strapi.policy(`${PLUGIN_PREFIX}${pluginName}.${policyName}`);
36
- return resolveHandler(policy);
23
+ return strapi.policy(`${PLUGIN_PREFIX}${pluginName}.${policyName}`);
37
24
  }
38
25
 
39
26
  if (apiName) {
40
- const policy = strapi.policy(`${API_PREFIX}${apiName}.${policyName}`);
41
- return resolveHandler(policy);
27
+ return strapi.policy(`${API_PREFIX}${apiName}.${policyName}`);
42
28
  }
43
29
  };
44
30
 
@@ -56,53 +42,68 @@ const globalPolicy = ({ method, endpoint, controller, action, plugin }) => {
56
42
  };
57
43
  };
58
44
 
59
- const bodyPolicy = async (ctx, next) => {
60
- const values = await next();
61
-
62
- if (_.isNil(ctx.body) && !_.isNil(values)) {
63
- ctx.body = values;
64
- }
45
+ const resolvePolicies = (config, { pluginName, apiName } = {}) => {
46
+ return config.map(policyConfig => {
47
+ return {
48
+ handler: getPolicy(policyConfig, { pluginName, apiName }),
49
+ config: policyConfig.config || {},
50
+ };
51
+ });
65
52
  };
66
53
 
67
- const get = (policy, { pluginName, apiName } = {}) => {
68
- if (typeof policy === 'function') {
69
- return policy;
70
- }
71
-
72
- const { policyName, args } = parsePolicy(policy);
73
-
74
- const resolvedPolicy = resolvePolicy(policyName);
54
+ const findPolicy = (name, { pluginName, apiName } = {}) => {
55
+ const resolvedPolicy = strapi.policy(name);
75
56
 
76
57
  if (resolvedPolicy !== undefined) {
77
- return _.isPlainObject(policy) ? resolvedPolicy(args) : resolvedPolicy;
58
+ return resolvedPolicy;
78
59
  }
79
60
 
80
- const localPolicy = searchLocalPolicy(policy, { pluginName, apiName });
61
+ const localPolicy = searchLocalPolicy(name, { pluginName, apiName });
81
62
 
82
63
  if (localPolicy !== undefined) {
83
64
  return localPolicy;
84
65
  }
85
66
 
86
- throw new Error(`Could not find policy "${policy}"`);
67
+ throw new Error(`Could not find policy "${name}"`);
87
68
  };
88
69
 
89
- const createPolicyFactory = (factoryCallback, options) => {
90
- const { validator, name = 'unnamed' } = options;
70
+ const getPolicy = (policyConfig, { pluginName, apiName } = {}) => {
71
+ if (typeof policyConfig === 'function') {
72
+ return policyConfig;
73
+ }
91
74
 
92
- const validate = (...args) => {
93
- try {
94
- validator(...args);
95
- } catch (e) {
96
- throw new Error(`Invalid objects submitted to "${name}" policy.`);
97
- }
98
- };
75
+ const { policyName, config } = parsePolicy(policyConfig);
76
+
77
+ const policy = findPolicy(policyName, { pluginName, apiName });
78
+
79
+ if (typeof policy === 'function') {
80
+ return policy;
81
+ }
99
82
 
100
- return options => {
83
+ if (policy.validator) {
84
+ policy.validator(config);
85
+ }
86
+
87
+ return policy.handler;
88
+ };
89
+
90
+ const createPolicy = options => {
91
+ const { name = 'unnamed', validator, handler } = options;
92
+
93
+ const wrappedValidator = config => {
101
94
  if (validator) {
102
- validate(options);
95
+ try {
96
+ validator(config);
97
+ } catch (e) {
98
+ throw new Error(`Invalid config passed to "${name}" policy.`);
99
+ }
103
100
  }
101
+ };
104
102
 
105
- return factoryCallback(options);
103
+ return {
104
+ name,
105
+ validator: wrappedValidator,
106
+ handler,
106
107
  };
107
108
  };
108
109
 
@@ -110,7 +111,6 @@ const createPolicyContext = (type, ctx) => {
110
111
  return Object.assign(
111
112
  {
112
113
  is: eq(type),
113
-
114
114
  get type() {
115
115
  return type;
116
116
  },
@@ -120,9 +120,9 @@ const createPolicyContext = (type, ctx) => {
120
120
  };
121
121
 
122
122
  module.exports = {
123
- get,
123
+ get: getPolicy,
124
+ resolve: resolvePolicies,
124
125
  globalPolicy,
125
- bodyPolicy,
126
- createPolicyFactory,
126
+ createPolicy,
127
127
  createPolicyContext,
128
128
  };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ // Code copied from the yup library (https://github.com/jquense/yup)
4
+ // https://github.com/jquense/yup/blob/2778b88bdacd5260d593c6468793da2e77daf21f/src/util/printValue.ts
5
+
6
+ const toString = Object.prototype.toString;
7
+ const errorToString = Error.prototype.toString;
8
+ const regExpToString = RegExp.prototype.toString;
9
+ const symbolToString = typeof Symbol !== 'undefined' ? Symbol.prototype.toString : () => '';
10
+
11
+ const SYMBOL_REGEXP = /^Symbol\((.*)\)(.*)$/;
12
+
13
+ function printNumber(val) {
14
+ if (val != +val) return 'NaN';
15
+ const isNegativeZero = val === 0 && 1 / val < 0;
16
+ return isNegativeZero ? '-0' : '' + val;
17
+ }
18
+
19
+ function printSimpleValue(val, quoteStrings = false) {
20
+ if (val == null || val === true || val === false) return '' + val;
21
+
22
+ const typeOf = typeof val;
23
+ if (typeOf === 'number') return printNumber(val);
24
+ if (typeOf === 'string') return quoteStrings ? `"${val}"` : val;
25
+ if (typeOf === 'function') return '[Function ' + (val.name || 'anonymous') + ']';
26
+ if (typeOf === 'symbol') return symbolToString.call(val).replace(SYMBOL_REGEXP, 'Symbol($1)');
27
+
28
+ const tag = toString.call(val).slice(8, -1);
29
+ if (tag === 'Date') return isNaN(val.getTime()) ? '' + val : val.toISOString(val);
30
+ if (tag === 'Error' || val instanceof Error) return '[' + errorToString.call(val) + ']';
31
+ if (tag === 'RegExp') return regExpToString.call(val);
32
+
33
+ return null;
34
+ }
35
+
36
+ function printValue(value, quoteStrings) {
37
+ let result = printSimpleValue(value, quoteStrings);
38
+ if (result !== null) return result;
39
+
40
+ return JSON.stringify(
41
+ value,
42
+ function(key, value) {
43
+ let result = printSimpleValue(this[key], quoteStrings);
44
+ if (result !== null) return result;
45
+ return value;
46
+ },
47
+ 2
48
+ );
49
+ }
50
+
51
+ module.exports = printValue;
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const { isArray } = require('lodash/fp');
4
+
5
+ const traverseEntity = require('../traverse-entity');
6
+ const { getNonWritableAttributes } = require('../content-types');
7
+ const pipeAsync = require('../pipe-async');
8
+
9
+ const visitors = require('./visitors');
10
+ const sanitizers = require('./sanitizers');
11
+
12
+ 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
+ return pipeAsync(...transforms)(data);
32
+ },
33
+
34
+ output(data, schema, { auth } = {}) {
35
+ if (isArray(data)) {
36
+ return Promise.all(data.map(entry => this.output(entry, schema, { auth })));
37
+ }
38
+
39
+ const transforms = [sanitizers.defaultSanitizeOutput(schema)];
40
+
41
+ if (auth) {
42
+ transforms.push(traverseEntity(visitors.removeRestrictedRelations(auth), { schema }));
43
+ }
44
+
45
+ return pipeAsync(...transforms)(data);
46
+ },
47
+ },
48
+
49
+ sanitizers,
50
+ visitors,
51
+ };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ const { curry } = require('lodash/fp');
4
+
5
+ const pipeAsync = require('../pipe-async');
6
+ const traverseEntity = require('../traverse-entity');
7
+
8
+ const { removePassword, removePrivate } = require('./visitors');
9
+
10
+ const sanitizePasswords = curry((schema, entity) => {
11
+ return traverseEntity(removePassword, { schema }, entity);
12
+ });
13
+
14
+ const sanitizePrivates = curry((schema, entity) => {
15
+ return traverseEntity(removePrivate, { schema }, entity);
16
+ });
17
+
18
+ const defaultSanitizeOutput = curry((schema, entity) => {
19
+ return pipeAsync(sanitizePrivates(schema), sanitizePasswords(schema))(entity);
20
+ });
21
+
22
+ module.exports = {
23
+ sanitizePasswords,
24
+ sanitizePrivates,
25
+ defaultSanitizeOutput,
26
+ };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const { isArray, toPath } = require('lodash/fp');
4
+
5
+ module.exports = (allowedFields = null) => ({ key, path }, { remove }) => {
6
+ // All fields are allowed
7
+ if (allowedFields === null) {
8
+ return;
9
+ }
10
+
11
+ // Ignore invalid formats
12
+ if (!isArray(allowedFields)) {
13
+ return;
14
+ }
15
+
16
+ const containedPaths = getContainedPaths(path);
17
+
18
+ /**
19
+ * Tells if the current path should be kept or not based
20
+ * on the success of the check functions for any of the allowed paths.
21
+ *
22
+ * The check functions are defined as follow:
23
+ *
24
+ * `containedPaths.includes(p)`
25
+ * @example
26
+ * ```js
27
+ * const path = 'foo.bar.field';
28
+ * const p = 'foo.bar';
29
+ * // it should match
30
+ *
31
+ * const path = 'foo.bar.field';
32
+ * const p = 'bar.foo';
33
+ * // it shouldn't match
34
+ *
35
+ * const path = 'foo.bar';
36
+ * const p = 'foo.bar.field';
37
+ * // it should match but isn't handled by this check
38
+ * ```
39
+ *
40
+ * `p.startsWith(`${path}.`)`
41
+ * @example
42
+ * ```js
43
+ * const path = 'foo.bar';
44
+ * const p = 'foo.bar.field';
45
+ * // it should match
46
+ *
47
+ * const path = 'foo.bar.field';
48
+ * const p = 'bar.foo';
49
+ * // it shouldn't match
50
+ *
51
+ * const path = 'foo.bar.field';
52
+ * const p = 'foo.bar';
53
+ * // it should match but isn't handled by this check
54
+ * ```
55
+ */
56
+ const isPathAllowed = allowedFields.some(
57
+ p => containedPaths.includes(p) || p.startsWith(`${path}.`)
58
+ );
59
+
60
+ if (isPathAllowed) {
61
+ return;
62
+ }
63
+
64
+ // Remove otherwise
65
+ remove(key);
66
+ };
67
+
68
+ /**
69
+ * Retrieve the list of allowed paths based on the given path
70
+ *
71
+ * @param {string} path
72
+ * @return {string[]}
73
+ *
74
+ * @example
75
+ * ```js
76
+ * const containedPaths = getContainedPaths('foo');
77
+ * // ['foo']
78
+ *
79
+ * * const containedPaths = getContainedPaths('foo.bar');
80
+ * // ['foo', 'foo.bar']
81
+ *
82
+ * * const containedPaths = getContainedPaths('foo.bar.field');
83
+ * // ['foo', 'foo.bar', 'foo.bar.field']
84
+ * ```
85
+ */
86
+ const getContainedPaths = path => {
87
+ const parts = toPath(path);
88
+
89
+ return parts.reduce((acc, value, index, list) => {
90
+ return [...acc, list.slice(0, index + 1).join('.')];
91
+ }, []);
92
+ };
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ removePassword: require('./remove-password'),
5
+ removePrivate: require('./remove-private'),
6
+ removeRestrictedRelations: require('./remove-restricted-relations'),
7
+ allowedFields: require('./allowed-fields'),
8
+ restrictedFields: require('./restricted-fields'),
9
+ };
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ module.exports = ({ key, attribute }, { remove }) => {
4
+ if (attribute.type === 'password') {
5
+ remove(key);
6
+ }
7
+ };
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ const { isPrivateAttribute } = require('../../content-types');
4
+
5
+ module.exports = ({ schema, key }, { remove }) => {
6
+ const isPrivate = isPrivateAttribute(schema, key);
7
+
8
+ if (isPrivate) {
9
+ remove(key);
10
+ }
11
+ };
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const ACTIONS_TO_VERIFY = ['find'];
4
+
5
+ module.exports = auth => async ({ data, key, attribute }, { remove, set }) => {
6
+ const isRelation = attribute.type === 'relation';
7
+
8
+ if (!isRelation) {
9
+ return;
10
+ }
11
+
12
+ const handleMorphRelation = async () => {
13
+ const newMorphValue = [];
14
+
15
+ for (const element of data[key]) {
16
+ const scopes = ACTIONS_TO_VERIFY.map(action => `${element.__type}.${action}`);
17
+ const isAllowed = await hasAccessToSomeScopes(scopes, auth);
18
+
19
+ if (isAllowed) {
20
+ newMorphValue.push(element);
21
+ }
22
+ }
23
+
24
+ // If the new value is empty, remove the relation completely
25
+ if (newMorphValue.length === 0) {
26
+ remove(key);
27
+ } else {
28
+ set(key, newMorphValue);
29
+ }
30
+ };
31
+
32
+ const handleRegularRelation = async () => {
33
+ const scopes = ACTIONS_TO_VERIFY.map(action => `${attribute.target}.${action}`);
34
+
35
+ const isAllowed = await hasAccessToSomeScopes(scopes, auth);
36
+
37
+ // If the authenticated user don't have access to any of the scopes, then remove the field
38
+ if (!isAllowed) {
39
+ remove(key);
40
+ }
41
+ };
42
+
43
+ const isMorphRelation = attribute.relation.toLowerCase().startsWith('morph');
44
+
45
+ // Polymorphic relations
46
+ if (isMorphRelation) {
47
+ await handleMorphRelation();
48
+ }
49
+
50
+ // Regular relations
51
+ else {
52
+ await handleRegularRelation();
53
+ }
54
+ };
55
+
56
+ const hasAccessToSomeScopes = async (scopes, auth) => {
57
+ for (const scope of scopes) {
58
+ try {
59
+ await strapi.auth.verify(auth, { scope });
60
+ return true;
61
+ } catch {
62
+ continue;
63
+ }
64
+ }
65
+
66
+ return false;
67
+ };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ const { isArray } = require('lodash/fp');
4
+
5
+ module.exports = (restrictedFields = null) => ({ key, path }, { remove }) => {
6
+ // Remove all fields
7
+ if (restrictedFields === null) {
8
+ remove(key);
9
+ return;
10
+ }
11
+
12
+ // Ignore invalid formats
13
+ if (!isArray(restrictedFields)) {
14
+ return;
15
+ }
16
+
17
+ // Remove if an exact match was found
18
+ if (restrictedFields.includes(path)) {
19
+ remove(key);
20
+ return;
21
+ }
22
+
23
+ // Remove nested matches
24
+ const isRestrictedNested = restrictedFields.some(allowedPath =>
25
+ path.startsWith(`${allowedPath}.`)
26
+ );
27
+ if (isRestrictedNested) {
28
+ remove(key);
29
+ return;
30
+ }
31
+ };
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ const { cloneDeep, isObject, isArray, isNil, curry } = require('lodash/fp');
4
+
5
+ const traverseEntity = async (visitor, options, entity) => {
6
+ const { path = null, schema } = options;
7
+
8
+ // End recursion
9
+ if (!isObject(entity) || isNil(schema)) {
10
+ return entity;
11
+ }
12
+
13
+ // Don't mutate the original entity object
14
+ const copy = cloneDeep(entity);
15
+
16
+ for (const key of Object.keys(copy)) {
17
+ // Retrieve the attribute definition associated to the key from the schema
18
+ const attribute = schema.attributes[key];
19
+
20
+ // If the attribute doesn't exist within the schema, ignore it
21
+ if (isNil(attribute)) {
22
+ continue;
23
+ }
24
+
25
+ const newPath = path ? `${path}.${key}` : key;
26
+
27
+ // Visit the current attribute
28
+ const visitorOptions = { data: copy, schema, key, value: copy[key], attribute, path: newPath };
29
+ const visitorUtils = createVisitorUtils({ data: copy });
30
+
31
+ await visitor(visitorOptions, visitorUtils);
32
+
33
+ // Extract the value for the current key (after calling the visitor)
34
+ const value = copy[key];
35
+
36
+ // Ignore Nil values
37
+ if (isNil(value)) {
38
+ continue;
39
+ }
40
+
41
+ const isRelation = attribute.type === 'relation';
42
+ const isComponent = attribute.type === 'component';
43
+ const isDynamicZone = attribute.type === 'dynamiczone';
44
+
45
+ if (isRelation) {
46
+ const isMorphRelation = attribute.relation.toLowerCase().startsWith('morph');
47
+
48
+ const traverseTarget = entry => {
49
+ // Handle polymorphic relationships
50
+ const targetSchemaUID = isMorphRelation ? entry.__type : attribute.target;
51
+ const targetSchema = strapi.getModel(targetSchemaUID);
52
+
53
+ const traverseOptions = { schema: targetSchema, path: newPath };
54
+
55
+ return traverseEntity(visitor, traverseOptions, entry);
56
+ };
57
+
58
+ // need to update copy
59
+ copy[key] = isArray(value)
60
+ ? await Promise.all(value.map(traverseTarget))
61
+ : await traverseTarget(value);
62
+ }
63
+
64
+ if (isComponent) {
65
+ const targetSchema = strapi.getModel(attribute.component);
66
+ const traverseOptions = { schema: targetSchema, path: newPath };
67
+
68
+ const traverseComponent = entry => traverseEntity(visitor, traverseOptions, entry);
69
+
70
+ copy[key] = isArray(value)
71
+ ? await Promise.all(value.map(traverseComponent))
72
+ : await traverseComponent(value);
73
+ }
74
+
75
+ if (isDynamicZone && isArray(value)) {
76
+ const visitDynamicZoneEntry = entry => {
77
+ const targetSchema = strapi.getModel(entry.__component);
78
+ const traverseOptions = { schema: targetSchema, path: newPath };
79
+
80
+ return traverseEntity(visitor, traverseOptions, entry);
81
+ };
82
+
83
+ copy[key] = await Promise.all(value.map(visitDynamicZoneEntry));
84
+ }
85
+ }
86
+
87
+ return copy;
88
+ };
89
+
90
+ const createVisitorUtils = ({ data }) => ({
91
+ remove(key) {
92
+ delete data[key];
93
+ },
94
+
95
+ set(key, value) {
96
+ data[key] = value;
97
+ },
98
+ });
99
+
100
+ module.exports = curry(traverseEntity);
package/lib/validators.js CHANGED
@@ -2,7 +2,10 @@
2
2
 
3
3
  const yup = require('yup');
4
4
  const _ = require('lodash');
5
+ const { defaults } = require('lodash/fp');
5
6
  const utils = require('./string-formatting');
7
+ const { YupValidationError } = require('./errors');
8
+ const printValue = require('./print-value');
6
9
 
7
10
  const MixedSchemaType = yup.mixed;
8
11
 
@@ -56,27 +59,53 @@ class StrapiIDSchema extends MixedSchemaType {
56
59
 
57
60
  yup.strapiID = () => new StrapiIDSchema();
58
61
 
59
- /**
60
- * Returns a formatted error for http responses
61
- * @param {Object} validationError - a Yup ValidationError
62
- */
63
- const formatYupErrors = validationError => {
64
- if (!validationError.inner) {
65
- throw new Error('invalid.input');
66
- }
62
+ const handleYupError = (error, errorMessage) => {
63
+ throw new YupValidationError(error, errorMessage);
64
+ };
65
+
66
+ const defaultValidationParam = { strict: true, abortEarly: false };
67
67
 
68
- if (validationError.inner.length === 0) {
69
- if (validationError.path === undefined) return validationError.errors;
70
- return { [validationError.path]: validationError.errors };
68
+ const validateYupSchema = (schema, options = {}) => async (body, errorMessage) => {
69
+ try {
70
+ const optionsWithDefaults = defaults(defaultValidationParam, options);
71
+ return await schema.validate(body, optionsWithDefaults);
72
+ } catch (e) {
73
+ handleYupError(e, errorMessage);
71
74
  }
75
+ };
72
76
 
73
- return validationError.inner.reduce((acc, err) => {
74
- acc[err.path] = err.errors;
75
- return acc;
76
- }, {});
77
+ const validateYupSchemaSync = (schema, options = {}) => (body, errorMessage) => {
78
+ try {
79
+ const optionsWithDefaults = defaults(defaultValidationParam, options);
80
+ return schema.validateSync(body, optionsWithDefaults);
81
+ } catch (e) {
82
+ handleYupError(e, errorMessage);
83
+ }
77
84
  };
78
85
 
86
+ // Temporary fix of this issue : https://github.com/jquense/yup/issues/616
87
+ yup.setLocale({
88
+ mixed: {
89
+ notType({ path, type, value, originalValue }) {
90
+ let isCast = originalValue != null && originalValue !== value;
91
+ let msg =
92
+ `${path} must be a \`${type}\` type, ` +
93
+ `but the final value was: \`${printValue(value, true)}\`` +
94
+ (isCast ? ` (cast from the value \`${printValue(originalValue, true)}\`).` : '.');
95
+
96
+ /* Remove comment that is not supposed to be seen by the enduser
97
+ if (value === null) {
98
+ msg += `\n If "null" is intended as an empty value be sure to mark the schema as \`.nullable()\``;
99
+ }
100
+ */
101
+ return msg;
102
+ },
103
+ },
104
+ });
105
+
79
106
  module.exports = {
80
107
  yup,
81
- formatYupErrors,
108
+ handleYupError,
109
+ validateYupSchema,
110
+ validateYupSchemaSync,
82
111
  };
package/package.json CHANGED
@@ -1,45 +1,46 @@
1
1
  {
2
2
  "name": "@strapi/utils",
3
- "version": "4.0.0-beta.2",
3
+ "version": "4.0.0-beta.20",
4
4
  "description": "Shared utilities for the Strapi packages",
5
- "homepage": "https://strapi.io",
6
5
  "keywords": [
7
6
  "strapi",
8
7
  "utils"
9
8
  ],
10
- "directories": {
11
- "lib": "./lib"
9
+ "homepage": "https://strapi.io",
10
+ "bugs": {
11
+ "url": "https://github.com/strapi/strapi/issues"
12
12
  },
13
- "main": "./lib",
14
- "dependencies": {
15
- "@sindresorhus/slugify": "1.1.0",
16
- "date-fns": "2.24.0",
17
- "lodash": "4.17.21",
18
- "yup": "0.32.9"
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git://github.com/strapi/strapi.git"
19
16
  },
17
+ "license": "SEE LICENSE IN LICENSE",
20
18
  "author": {
19
+ "name": "Strapi Solutions SAS",
21
20
  "email": "hi@strapi.io",
22
- "name": "Strapi team",
23
21
  "url": "https://strapi.io"
24
22
  },
25
23
  "maintainers": [
26
24
  {
27
- "name": "Strapi team",
25
+ "name": "Strapi Solutions SAS",
28
26
  "email": "hi@strapi.io",
29
27
  "url": "https://strapi.io"
30
28
  }
31
29
  ],
32
- "repository": {
33
- "type": "git",
34
- "url": "git://github.com/strapi/strapi.git"
30
+ "main": "./lib",
31
+ "directories": {
32
+ "lib": "./lib"
35
33
  },
36
- "bugs": {
37
- "url": "https://github.com/strapi/strapi/issues"
34
+ "dependencies": {
35
+ "@sindresorhus/slugify": "1.1.0",
36
+ "date-fns": "2.24.0",
37
+ "http-errors": "1.8.0",
38
+ "lodash": "4.17.21",
39
+ "yup": "0.32.9"
38
40
  },
39
41
  "engines": {
40
42
  "node": ">=12.x.x <=16.x.x",
41
43
  "npm": ">=6.0.0"
42
44
  },
43
- "license": "SEE LICENSE IN LICENSE",
44
- "gitHead": "020680cbb38b6281b3e16df264b9388a087b2b49"
45
+ "gitHead": "b4993dab9f6dbc583709167f459b6f00e0b4baa6"
45
46
  }
@@ -1,171 +0,0 @@
1
- 'use strict';
2
-
3
- const _ = require('lodash');
4
- const {
5
- constants,
6
- isPrivateAttribute,
7
- getNonWritableAttributes,
8
- getNonVisibleAttributes,
9
- getWritableAttributes,
10
- } = require('./content-types');
11
-
12
- const { ID_ATTRIBUTE, CREATED_AT_ATTRIBUTE, UPDATED_AT_ATTRIBUTE } = constants;
13
-
14
- const sanitizeEntity = (dataSource, options) => {
15
- const { model, withPrivate = false, isOutput = true, includeFields = null } = options;
16
-
17
- if (typeof dataSource !== 'object' || _.isNil(dataSource)) {
18
- return dataSource;
19
- }
20
-
21
- const data = parseOriginalData(dataSource);
22
-
23
- if (typeof data !== 'object' || _.isNil(data)) {
24
- return data;
25
- }
26
-
27
- if (_.isArray(data)) {
28
- return data.map(entity => sanitizeEntity(entity, options));
29
- }
30
-
31
- if (_.isNil(model)) {
32
- if (isOutput) {
33
- return null;
34
- } else {
35
- return data;
36
- }
37
- }
38
-
39
- const { attributes } = model;
40
- const allowedFields = getAllowedFields({ includeFields, model, isOutput });
41
-
42
- const reducerFn = (acc, value, key) => {
43
- const attribute = attributes[key];
44
- const allowedFieldsHasKey = allowedFields.includes(key);
45
-
46
- if (shouldRemoveAttribute(model, key, attribute, { withPrivate, isOutput })) {
47
- return acc;
48
- }
49
-
50
- // Relations
51
- const isRelation = attribute && ['relation', 'component'].includes(attribute.type);
52
- if (isRelation) {
53
- const relation = attribute && (attribute.target || attribute.component);
54
-
55
- if (_.isNil(value)) {
56
- return { ...acc, [key]: value };
57
- }
58
-
59
- const [nextFields, isAllowed] = includeFields
60
- ? getNextFields(allowedFields, key, { allowedFieldsHasKey })
61
- : [null, true];
62
-
63
- if (!isAllowed) {
64
- return acc;
65
- }
66
-
67
- const baseOptions = {
68
- withPrivate,
69
- isOutput,
70
- includeFields: nextFields,
71
- };
72
-
73
- let sanitizeFn;
74
- if (attribute.relation && attribute.relation.toLowerCase().includes('morph')) {
75
- sanitizeFn = entity => {
76
- if (_.isNil(entity) || !_.has(entity, '__type')) {
77
- return entity;
78
- }
79
-
80
- return sanitizeEntity(entity, { model: strapi.getModel(entity.__type), ...baseOptions });
81
- };
82
- } else {
83
- sanitizeFn = entity => {
84
- return sanitizeEntity(entity, { model: strapi.getModel(relation), ...baseOptions });
85
- };
86
- }
87
-
88
- const nextVal = Array.isArray(value) ? value.map(sanitizeFn) : sanitizeFn(value);
89
-
90
- return { ...acc, [key]: nextVal };
91
- }
92
-
93
- const isAllowedField = !includeFields || allowedFieldsHasKey;
94
-
95
- // Dynamic zones
96
- if (attribute && attribute.type === 'dynamiczone' && value !== null && isAllowedField) {
97
- const nextVal = value.map(elem =>
98
- sanitizeEntity(elem, {
99
- model: strapi.getModel(elem.__component),
100
- withPrivate,
101
- isOutput,
102
- })
103
- );
104
- return { ...acc, [key]: nextVal };
105
- }
106
-
107
- // Other fields
108
- if (isAllowedField) {
109
- return { ...acc, [key]: value };
110
- }
111
-
112
- return acc;
113
- };
114
-
115
- return _.reduce(data, reducerFn, {});
116
- };
117
-
118
- const parseOriginalData = data => (_.isFunction(data.toJSON) ? data.toJSON() : data);
119
-
120
- const COMPONENT_FIELDS = ['__component'];
121
- const STATIC_FIELDS = [ID_ATTRIBUTE];
122
-
123
- const getAllowedFields = ({ includeFields, model, isOutput }) => {
124
- const nonWritableAttributes = getNonWritableAttributes(model);
125
- const nonVisibleAttributes = getNonVisibleAttributes(model);
126
-
127
- const writableAttributes = getWritableAttributes(model);
128
-
129
- const nonVisibleWritableAttributes = _.intersection(writableAttributes, nonVisibleAttributes);
130
-
131
- return _.uniq(
132
- _.concat(
133
- includeFields || [],
134
- ...(isOutput
135
- ? [
136
- STATIC_FIELDS,
137
- CREATED_AT_ATTRIBUTE,
138
- UPDATED_AT_ATTRIBUTE,
139
- COMPONENT_FIELDS,
140
- ...nonWritableAttributes,
141
- ...nonVisibleAttributes,
142
- ]
143
- : [STATIC_FIELDS, COMPONENT_FIELDS, ...nonVisibleWritableAttributes])
144
- )
145
- );
146
- };
147
-
148
- const getNextFields = (fields, key, { allowedFieldsHasKey }) => {
149
- const searchStr = `${key}.`;
150
-
151
- const transformedFields = (fields || [])
152
- .filter(field => field.startsWith(searchStr))
153
- .map(field => field.replace(searchStr, ''));
154
-
155
- const isAllowed = allowedFieldsHasKey || transformedFields.length > 0;
156
- const nextFields = allowedFieldsHasKey ? null : transformedFields;
157
-
158
- return [nextFields, isAllowed];
159
- };
160
-
161
- const shouldRemoveAttribute = (model, key, attribute = {}, { withPrivate, isOutput }) => {
162
- const isPassword = attribute.type === 'password';
163
- const isPrivate = isPrivateAttribute(model, key);
164
-
165
- const shouldRemovePassword = isOutput;
166
- const shouldRemovePrivate = !withPrivate && isOutput;
167
-
168
- return !!((isPassword && shouldRemovePassword) || (isPrivate && shouldRemovePrivate));
169
- };
170
-
171
- module.exports = sanitizeEntity;