diff --git a/packages/core/utils/src/content-types.ts b/packages/core/utils/src/content-types.ts index 8be72b317a..b00fd577f4 100644 --- a/packages/core/utils/src/content-types.ts +++ b/packages/core/utils/src/content-types.ts @@ -1,6 +1,13 @@ import _ from 'lodash'; import { has } from 'lodash/fp'; -import type { Model, Kind, Attribute, RelationalAttribute } from './types'; +import type { + Model, + Kind, + Attribute, + RelationalAttribute, + ComponentAttribute, + DynamicZoneAttribute, +} from './types'; const SINGLE_TYPE = 'singleType'; const COLLECTION_TYPE = 'collectionType'; @@ -113,10 +120,11 @@ const isMediaAttribute = (attribute: Attribute) => attribute?.type === 'media'; const isRelationalAttribute = (attribute: Attribute): attribute is RelationalAttribute => attribute?.type === 'relation'; -const isComponentAttribute = (attribute: Attribute) => +const isComponentAttribute = (attribute: Attribute): attribute is ComponentAttribute => ['component', 'dynamiczone'].includes(attribute?.type); -const isDynamicZoneAttribute = (attribute: Attribute) => attribute?.type === 'dynamiczone'; +const isDynamicZoneAttribute = (attribute: Attribute): attribute is DynamicZoneAttribute => + attribute?.type === 'dynamiczone'; const isMorphToRelationalAttribute = (attribute: Attribute) => { return isRelationalAttribute(attribute) && attribute?.relation?.startsWith?.('morphTo'); }; diff --git a/packages/core/utils/src/convert-query-params.js b/packages/core/utils/src/convert-query-params.ts similarity index 76% rename from packages/core/utils/src/convert-query-params.js rename to packages/core/utils/src/convert-query-params.ts index efc279535e..2486eb3fea 100644 --- a/packages/core/utils/src/convert-query-params.js +++ b/packages/core/utils/src/convert-query-params.ts @@ -1,36 +1,88 @@ +/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable max-classes-per-file */ -'use strict'; - /** * Converts the standard Strapi REST query params to a more usable format for querying * You can read more here: https://docs.strapi.io/developer-docs/latest/developer-resources/database-apis-reference/rest-api.html#filters */ -const { +import { isNil, toNumber, isInteger, has, isEmpty, isObject, - isPlainObject, cloneDeep, get, mergeAll, -} = require('lodash/fp'); -const _ = require('lodash'); -const parseType = require('./parse-type'); -const contentTypesUtils = require('./content-types'); -const { PaginationError } = require('./errors'); -const { + isArray, + isString, +} from 'lodash/fp'; +import _ from 'lodash'; +import parseType from './parse-type'; +import * as contentTypesUtils from './content-types'; +import { PaginationError } from './errors'; +import { isMediaAttribute, isDynamicZoneAttribute, isMorphToRelationalAttribute, -} = require('./content-types'); +} from './content-types'; +import { Model } from './types'; const { PUBLISHED_AT_ATTRIBUTE } = contentTypesUtils.constants; +type SortOrder = 'asc' | 'desc'; + +interface SortMap { + [key: string]: SortOrder | SortMap; +} + +type SortQuery = string | string[] | object; + +type FieldsQuery = string | string[]; +interface FiltersQuery {} + +type PopulateParams = { + sort?: SortQuery; + fields?: FieldsQuery; + filters?: FiltersQuery; + populate?: PopulateQuery; + on: { + [key: string]: PopulateParams; + }; +}; + +type PopulateQuery = string | string[] | PopulateParams; + +interface Query { + sort?: SortQuery; + fields?: FieldsQuery; + filters?: FiltersQuery; + populate?: PopulateQuery; + count: boolean; + ordering: unknown; + _q?: string; + limit?: number | string; + start?: number | string; + page?: number | string; + pageSize?: number | string; +} + +interface ConvertedQuery { + orderBy?: SortQuery; + select?: FieldsQuery; + where?: FiltersQuery; + populate?: PopulateQuery; + count: boolean; + ordering: unknown; + _q?: string; + limit?: number; + offset?: number; + page?: number; + pageSize?: number; +} + class InvalidOrderError extends Error { constructor() { super(); @@ -45,41 +97,47 @@ class InvalidSortError extends Error { } } -const validateOrder = (order) => { +function validateOrder(order: string): asserts order is SortOrder { if (!['asc', 'desc'].includes(order.toLocaleLowerCase())) { throw new InvalidOrderError(); } -}; +} -const convertCountQueryParams = (countQuery) => { +const convertCountQueryParams = (countQuery: unknown): boolean => { return parseType({ type: 'boolean', value: countQuery }); }; -const convertOrderingQueryParams = (ordering) => { +const convertOrderingQueryParams = (ordering: unknown) => { return ordering; }; +const isPlainObject = (value: unknown): value is Record => _.isPlainObject(value); +const isStringArray = (value: unknown): value is string[] => + isArray(value) && value.every(isString); + /** * Sort query parser - * @param {string} sortQuery - ex: id:asc,price:desc */ -const convertSortQueryParams = (sortQuery) => { +const convertSortQueryParams = (sortQuery: SortQuery): SortMap | SortMap[] => { if (typeof sortQuery === 'string') { - return sortQuery.split(',').map((value) => convertSingleSortQueryParam(value)); + return convertStringSortQueryParam(sortQuery); + } + if (isStringArray(sortQuery)) { + return sortQuery.flatMap((sortValue: string) => convertStringSortQueryParam(sortValue)); } - if (Array.isArray(sortQuery)) { - return sortQuery.flatMap((sortValue) => convertSortQueryParams(sortValue)); - } - - if (_.isPlainObject(sortQuery)) { + if (isPlainObject(sortQuery)) { return convertNestedSortQueryParam(sortQuery); } throw new InvalidSortError(); }; -const convertSingleSortQueryParam = (sortQuery) => { +const convertStringSortQueryParam = (sortQuery: string): SortMap[] => { + return sortQuery.split(',').map((value) => convertSingleSortQueryParam(value)); +}; + +const convertSingleSortQueryParam = (sortQuery: string): SortMap => { // split field and order param with default order to ascending const [field, order = 'asc'] = sortQuery.split(':'); @@ -92,17 +150,19 @@ const convertSingleSortQueryParam = (sortQuery) => { return _.set({}, field, order); }; -const convertNestedSortQueryParam = (sortQuery) => { - const transformedSort = {}; +const convertNestedSortQueryParam = (sortQuery: Record): SortMap => { + const transformedSort: SortMap = {}; for (const field of Object.keys(sortQuery)) { const order = sortQuery[field]; // this is a deep sort - if (_.isPlainObject(order)) { + if (isPlainObject(order)) { transformedSort[field] = convertNestedSortQueryParam(order); - } else { + } else if (typeof order === 'string') { validateOrder(order); transformedSort[field] = order; + } else { + throw Error(`Invalid sort type expected object or string got ${typeof order}`); } } @@ -111,9 +171,8 @@ const convertNestedSortQueryParam = (sortQuery) => { /** * Start query parser - * @param {string} startQuery */ -const convertStartQueryParams = (startQuery) => { +const convertStartQueryParams = (startQuery: unknown): number => { const startAsANumber = _.toNumber(startQuery); if (!_.isInteger(startAsANumber) || startAsANumber < 0) { @@ -125,21 +184,22 @@ const convertStartQueryParams = (startQuery) => { /** * Limit query parser - * @param {string} limitQuery */ -const convertLimitQueryParams = (limitQuery) => { +const convertLimitQueryParams = (limitQuery: unknown): number | undefined => { const limitAsANumber = _.toNumber(limitQuery); if (!_.isInteger(limitAsANumber) || (limitAsANumber !== -1 && limitAsANumber < 0)) { throw new Error(`convertLimitQueryParams expected a positive integer got ${limitAsANumber}`); } - if (limitAsANumber === -1) return null; + if (limitAsANumber === -1) { + return undefined; + } return limitAsANumber; }; -const convertPageQueryParams = (page) => { +const convertPageQueryParams = (page: unknown): number => { const pageVal = toNumber(page); if (!isInteger(pageVal) || pageVal <= 0) { @@ -151,7 +211,7 @@ const convertPageQueryParams = (page) => { return pageVal; }; -const convertPageSizeQueryParams = (pageSize, page) => { +const convertPageSizeQueryParams = (pageSize: unknown, page: unknown): number => { const pageSizeVal = toNumber(pageSize); if (!isInteger(pageSizeVal) || pageSizeVal <= 0) { @@ -163,7 +223,12 @@ const convertPageSizeQueryParams = (pageSize, page) => { return pageSizeVal; }; -const validatePaginationParams = (page, pageSize, start, limit) => { +const validatePaginationParams = ( + page: unknown, + pageSize: unknown, + start: unknown, + limit: unknown +) => { const isPagePagination = !isNil(page) || !isNil(pageSize); const isOffsetPagination = !isNil(start) || !isNil(limit); @@ -183,7 +248,7 @@ class InvalidPopulateError extends Error { } // NOTE: we could support foo.* or foo.bar.* etc later on -const convertPopulateQueryParams = (populate, schema, depth = 0) => { +const convertPopulateQueryParams = (populate: PopulateQuery, schema: Model, depth = 0) => { if (depth === 0 && populate === '*') { return true; } @@ -212,7 +277,7 @@ const convertPopulateQueryParams = (populate, schema, depth = 0) => { throw new InvalidPopulateError(); }; -const convertPopulateObject = (populate, schema) => { +const convertPopulateObject = (populate: PopulateParams, schema: Model) => { if (!schema) { return {}; } @@ -252,7 +317,7 @@ const convertPopulateObject = (populate, schema) => { // TODO: This is a query's populate fallback for DynamicZone and is kept for legacy purpose. // Removing it could break existing user queries but it should be removed in V5. - if (attribute.type === 'dynamiczone') { + if (isDynamicZoneAttribute(attribute)) { const populates = attribute.components .map((uid) => strapi.getModel(uid)) .map((schema) => convertNestedPopulate(subPopulate, schema)) @@ -303,7 +368,7 @@ const convertPopulateObject = (populate, schema) => { }, {}); }; -const convertNestedPopulate = (subPopulate, schema) => { +const convertNestedPopulate = (subPopulate: PopulateQuery, schema: Model) => { if (_.isString(subPopulate)) { return parseType({ type: 'boolean', value: subPopulate, forceCast: true }); } @@ -319,7 +384,7 @@ const convertNestedPopulate = (subPopulate, schema) => { const { sort, filters, fields, populate, count, ordering, page, pageSize, start, limit } = subPopulate; - const query = {}; + const query: ConvertedQuery = {}; if (sort) { query.orderBy = convertSortQueryParams(sort); @@ -368,7 +433,7 @@ const convertNestedPopulate = (subPopulate, schema) => { return query; }; -const convertFieldsQueryParams = (fields, depth = 0) => { +const convertFieldsQueryParams = (fields: FieldsQuery, depth = 0): string[] | undefined => { if (depth === 0 && fields === '*') { return undefined; } @@ -378,16 +443,19 @@ const convertFieldsQueryParams = (fields, depth = 0) => { return _.uniq(['id', ...fieldsValues]); } - if (Array.isArray(fields)) { + if (isStringArray(fields)) { // map convert - const fieldsValues = fields.flatMap((value) => convertFieldsQueryParams(value, depth + 1)); + const fieldsValues = fields + .flatMap((value) => convertFieldsQueryParams(value, depth + 1)) + .filter((v) => !isNil(v)) as string[]; + return _.uniq(['id', ...fieldsValues]); } throw new Error('Invalid fields parameter. Expected a string or an array of strings'); }; -const convertFiltersQueryParams = (filters, schema) => { +const convertFiltersQueryParams = (filters: FiltersQuery, schema: Model) => { // Filters need to be either an array or an object // Here we're only checking for 'object' type since typeof [] => object and typeof {} => object if (!isObject(filters)) { @@ -400,7 +468,7 @@ const convertFiltersQueryParams = (filters, schema) => { return convertAndSanitizeFilters(filtersCopy, schema); }; -const convertAndSanitizeFilters = (filters, schema) => { +const convertAndSanitizeFilters = (filters: FiltersQuery, schema: Model) => { if (!isPlainObject(filters)) { return filters; } @@ -415,7 +483,7 @@ const convertAndSanitizeFilters = (filters, schema) => { ); } - const removeOperator = (operator) => delete filters[operator]; + const removeOperator = (operator: string) => delete filters[operator]; // Here, `key` can either be an operator or an attribute name for (const [key, value] of Object.entries(filters)) { @@ -494,11 +562,11 @@ const convertPublicationStateParams = (type, params = {}, query = {}) => { } }; -const transformParamsToQuery = (uid, params) => { +const transformParamsToQuery = (uid: string, params: Query) => { // NOTE: can be a CT, a Compo or nothing in the case of polymorphism (DZ & morph relations) const schema = strapi.getModel(uid); - const query = {}; + const query: ConvertedQuery = {}; const { _q, sort, filters, fields, populate, page, pageSize, start, limit } = params; @@ -545,7 +613,7 @@ const transformParamsToQuery = (uid, params) => { return query; }; -module.exports = { +export = { convertSortQueryParams, convertStartQueryParams, convertLimitQueryParams, diff --git a/packages/core/utils/src/parse-type.ts b/packages/core/utils/src/parse-type.ts index 53f2cdd45b..68d8dbabcf 100644 --- a/packages/core/utils/src/parse-type.ts +++ b/packages/core/utils/src/parse-type.ts @@ -7,7 +7,7 @@ const isDate = (v: unknown): v is Date => { return dates.isDate(v); }; -const parseTime = (value: string | Date) => { +const parseTime = (value: string | Date): string => { if (isDate(value)) { return dates.format(value, 'HH:mm:ss.SSS'); } @@ -61,8 +61,20 @@ const parseDateTimeOrTimestamp = (value: string | Date) => { } }; +type TypeMap = { + boolean: boolean; + integer: number; + biginteger: number; + float: number; + decimal: number; + time: string; + date: string; + timestamp: Date; + datetime: Date; +}; + interface ParseTypeOptions { - type: string; + type: keyof TypeMap; value: any; forceCast?: boolean; } @@ -70,10 +82,14 @@ interface ParseTypeOptions { /** * Cast basic values based on attribute type */ -const parseType = ({ type, value, forceCast = false }: ParseTypeOptions) => { +const parseType = (options: ParseTypeOptions) => { + const { type, value, forceCast = false } = options; + switch (type) { case 'boolean': { - if (typeof value === 'boolean') return value; + if (typeof value === 'boolean') { + return value; + } if (['true', 't', '1', 1].includes(value)) { return true; @@ -110,4 +126,4 @@ const parseType = ({ type, value, forceCast = false }: ParseTypeOptions) => { } }; -module.exports = parseType; +export default parseType; diff --git a/packages/core/utils/src/sanitize/index.ts b/packages/core/utils/src/sanitize/index.ts index ab8a6ef36a..5f9021605b 100644 --- a/packages/core/utils/src/sanitize/index.ts +++ b/packages/core/utils/src/sanitize/index.ts @@ -1,3 +1,4 @@ +import { CurriedFunction1 } from 'lodash'; import { isArray, cloneDeep } from 'lodash/fp'; import { getNonWritableAttributes } from '../content-types'; @@ -5,13 +6,24 @@ import { pipeAsync } from '../async'; import * as visitors from './visitors'; import * as sanitizers from './sanitizers'; -import traverseEntity from '../traverse-entity'; +import traverseEntity, { Data } from '../traverse-entity'; import { traverseQueryFilters, traverseQuerySort, traverseQueryPopulate } from '../traverse'; import { Model } from '../types'; +interface Options { + auth?: unknown; +} + +interface Sanitizer { + (schema: Model): CurriedFunction1>; +} +export interface SanitizeFunc { + (data: unknown, schema: Model, options?: Options): Promise; +} + const createContentAPISanitizers = () => { - const sanitizeInput = (data, schema: Model, { auth } = {}) => { + const sanitizeInput: SanitizeFunc = (data: unknown, schema: Model, { auth } = {}) => { if (isArray(data)) { return Promise.all(data.map((entry) => sanitizeInput(entry, schema, { auth }))); } @@ -31,12 +43,12 @@ const createContentAPISanitizers = () => { // Apply sanitizers from registry if exists strapi.sanitizers .get('content-api.input') - .forEach((sanitizer) => transforms.push(sanitizer(schema))); + .forEach((sanitizer: Sanitizer) => transforms.push(sanitizer(schema))); return pipeAsync(...transforms)(data); }; - const sanitizeOutput = async (data, schema: Model, { auth } = {}) => { + const sanitizeOutput: SanitizeFunc = async (data, schema: Model, { auth } = {}) => { if (isArray(data)) { const res = new Array(data.length); for (let i = 0; i < data.length; i += 1) { @@ -45,7 +57,7 @@ const createContentAPISanitizers = () => { return res; } - const transforms = [(data) => sanitizers.defaultSanitizeOutput(schema, data)]; + const transforms = [(data: Data) => sanitizers.defaultSanitizeOutput(schema, data)]; if (auth) { transforms.push(traverseEntity(visitors.removeRestrictedRelations(auth), { schema })); @@ -54,12 +66,16 @@ const createContentAPISanitizers = () => { // Apply sanitizers from registry if exists strapi.sanitizers .get('content-api.output') - .forEach((sanitizer) => transforms.push(sanitizer(schema))); + .forEach((sanitizer: Sanitizer) => transforms.push(sanitizer(schema))); return pipeAsync(...transforms)(data); }; - const sanitizeQuery = async (query, schema, { auth } = {}) => { + const sanitizeQuery = async ( + query: Record, + schema: Model, + { auth }: Options = {} + ) => { const { filters, sort, fields, populate } = query; const sanitizedQuery = cloneDeep(query); @@ -83,7 +99,7 @@ const createContentAPISanitizers = () => { return sanitizedQuery; }; - const sanitizeFilters = (filters, schema: Model, { auth } = {}) => { + const sanitizeFilters: SanitizeFunc = (filters, schema: Model, { auth } = {}) => { if (isArray(filters)) { return Promise.all(filters.map((filter) => sanitizeFilters(filter, schema, { auth }))); } @@ -97,7 +113,7 @@ const createContentAPISanitizers = () => { return pipeAsync(...transforms)(filters); }; - const sanitizeSort = (sort, schema: Model, { auth } = {}) => { + const sanitizeSort: SanitizeFunc = (sort, schema: Model, { auth } = {}) => { const transforms = [sanitizers.defaultSanitizeSort(schema)]; if (auth) { @@ -107,13 +123,13 @@ const createContentAPISanitizers = () => { return pipeAsync(...transforms)(sort); }; - const sanitizeFields = (fields, schema: Model) => { + const sanitizeFields: SanitizeFunc = (fields, schema: Model) => { const transforms = [sanitizers.defaultSanitizeFields(schema)]; return pipeAsync(...transforms)(fields); }; - const sanitizePopulate = (populate, schema: Model, { auth } = {}) => { + const sanitizePopulate: SanitizeFunc = (populate, schema: Model, { auth } = {}) => { const transforms = [sanitizers.defaultSanitizePopulate(schema)]; if (auth) { diff --git a/packages/core/utils/src/sanitize/sanitizers.ts b/packages/core/utils/src/sanitize/sanitizers.ts index a38a09e622..4fd9368a21 100644 --- a/packages/core/utils/src/sanitize/sanitizers.ts +++ b/packages/core/utils/src/sanitize/sanitizers.ts @@ -35,7 +35,7 @@ const defaultSanitizeOutput = async (schema: Model, entity: Data) => { ); }; -const defaultSanitizeFilters = curry((schema: Model, filters) => { +const defaultSanitizeFilters = curry((schema: Model, filters: unknown) => { return pipeAsync( // Remove dynamic zones from filters traverseQueryFilters(removeDynamicZones, { schema }), @@ -57,7 +57,7 @@ const defaultSanitizeFilters = curry((schema: Model, filters) => { )(filters); }); -const defaultSanitizeSort = curry((schema: Model, sort) => { +const defaultSanitizeSort = curry((schema: Model, sort: unknown) => { return pipeAsync( // Remove non attribute keys traverseQuerySort( @@ -94,11 +94,11 @@ const defaultSanitizeSort = curry((schema: Model, sort) => { )(sort); }); -const defaultSanitizeFields = curry((schema: Model, fields) => { +const defaultSanitizeFields = curry((schema: Model, fields: unknown) => { return pipeAsync( // Only keep scalar attributes traverseQueryFields( - ({ key, attribute }: , { remove }) => { + ({ key, attribute }, { remove }) => { if (isNil(attribute) || !isScalarAttribute(attribute)) { remove(key); } @@ -114,7 +114,7 @@ const defaultSanitizeFields = curry((schema: Model, fields) => { )(fields); }); -const defaultSanitizePopulate = curry((schema: Model, populate) => { +const defaultSanitizePopulate = curry((schema: Model, populate: unknown) => { return pipeAsync( traverseQueryPopulate( async ({ key, value, schema, attribute }, { set }) => { diff --git a/packages/core/utils/src/sanitize/visitors/allowed-fields.ts b/packages/core/utils/src/sanitize/visitors/allowed-fields.ts index 85254aa541..3f6f865349 100644 --- a/packages/core/utils/src/sanitize/visitors/allowed-fields.ts +++ b/packages/core/utils/src/sanitize/visitors/allowed-fields.ts @@ -1,5 +1,5 @@ import { isArray, isNil, toPath } from 'lodash/fp'; -import { Visitor } from '../../traverse-entity'; +import type { Visitor } from '../../traverse/factory'; export default (allowedFields: string[] | null = null): Visitor => ({ key, path: { attribute: path } }, { remove }) => { diff --git a/packages/core/utils/src/sanitize/visitors/remove-dynamic-zones.ts b/packages/core/utils/src/sanitize/visitors/remove-dynamic-zones.ts index 50ff254140..97a7e28b62 100644 --- a/packages/core/utils/src/sanitize/visitors/remove-dynamic-zones.ts +++ b/packages/core/utils/src/sanitize/visitors/remove-dynamic-zones.ts @@ -1,5 +1,5 @@ import { isDynamicZoneAttribute } from '../../content-types'; -import type { Visitor } from '../../traverse-entity'; +import type { Visitor } from '../../traverse/factory'; const visitor: Visitor = ({ key, attribute }, { remove }) => { if (isDynamicZoneAttribute(attribute)) { diff --git a/packages/core/utils/src/sanitize/visitors/remove-morph-to-relations.ts b/packages/core/utils/src/sanitize/visitors/remove-morph-to-relations.ts index 08619e79bb..9b80ee06b0 100644 --- a/packages/core/utils/src/sanitize/visitors/remove-morph-to-relations.ts +++ b/packages/core/utils/src/sanitize/visitors/remove-morph-to-relations.ts @@ -1,5 +1,5 @@ import { isMorphToRelationalAttribute } from '../../content-types'; -import type { Visitor } from '../../traverse-entity'; +import type { Visitor } from '../../traverse/factory'; const visitor: Visitor = ({ key, attribute }, { remove }) => { if (isMorphToRelationalAttribute(attribute)) { diff --git a/packages/core/utils/src/sanitize/visitors/remove-password.ts b/packages/core/utils/src/sanitize/visitors/remove-password.ts index aeac896cfa..87f6280154 100644 --- a/packages/core/utils/src/sanitize/visitors/remove-password.ts +++ b/packages/core/utils/src/sanitize/visitors/remove-password.ts @@ -1,4 +1,4 @@ -import type { Visitor } from '../../traverse-entity'; +import type { Visitor } from '../../traverse/factory'; const visitor: Visitor = ({ key, attribute }, { remove }) => { if (attribute?.type === 'password') { diff --git a/packages/core/utils/src/sanitize/visitors/remove-private.ts b/packages/core/utils/src/sanitize/visitors/remove-private.ts index eb55de187f..f545e06686 100644 --- a/packages/core/utils/src/sanitize/visitors/remove-private.ts +++ b/packages/core/utils/src/sanitize/visitors/remove-private.ts @@ -1,5 +1,5 @@ import { isPrivateAttribute } from '../../content-types'; -import type { Visitor } from '../../traverse-entity'; +import type { Visitor } from '../../traverse/factory'; const visitor: Visitor = ({ schema, key, attribute }, { remove }) => { if (!attribute) { diff --git a/packages/core/utils/src/sanitize/visitors/remove-restricted-relations.ts b/packages/core/utils/src/sanitize/visitors/remove-restricted-relations.ts index d7c64f6bee..706e0395f8 100644 --- a/packages/core/utils/src/sanitize/visitors/remove-restricted-relations.ts +++ b/packages/core/utils/src/sanitize/visitors/remove-restricted-relations.ts @@ -1,9 +1,11 @@ import * as contentTypeUtils from '../../content-types'; -import type { Visitor } from '../../traverse-entity'; +import type { Visitor } from '../../traverse/factory'; const ACTIONS_TO_VERIFY = ['find']; const { CREATED_BY_ATTRIBUTE, UPDATED_BY_ATTRIBUTE } = contentTypeUtils.constants; +type MorphArray = Array<{ __type: string }>; + export default (auth: unknown): Visitor => async ({ data, key, attribute, schema }, { remove, set }) => { if (!attribute) { @@ -19,7 +21,7 @@ export default (auth: unknown): Visitor => const handleMorphRelation = async () => { const newMorphValue: Record[] = []; - for (const element of data[key]) { + for (const element of (data as Record)[key]) { const scopes = ACTIONS_TO_VERIFY.map((action) => `${element.__type}.${action}`); const isAllowed = await hasAccessToSomeScopes(scopes, auth); diff --git a/packages/core/utils/src/sanitize/visitors/restricted-fields.ts b/packages/core/utils/src/sanitize/visitors/restricted-fields.ts index 4ba95cc567..280adc8a6a 100644 --- a/packages/core/utils/src/sanitize/visitors/restricted-fields.ts +++ b/packages/core/utils/src/sanitize/visitors/restricted-fields.ts @@ -1,5 +1,5 @@ import { isArray } from 'lodash/fp'; -import type { Visitor } from '../../traverse-entity'; +import type { Visitor } from '../../traverse/factory'; export default (restrictedFields: string[] | null = null): Visitor => ({ key, path: { attribute: path } }, { remove }) => { diff --git a/packages/core/utils/src/traverse-entity.ts b/packages/core/utils/src/traverse-entity.ts index 0dbd735dfc..8202b40fa7 100644 --- a/packages/core/utils/src/traverse-entity.ts +++ b/packages/core/utils/src/traverse-entity.ts @@ -1,6 +1,6 @@ import { clone, isObject, isArray, isNil, curry } from 'lodash/fp'; import { Attribute, Model } from './types'; -import { isRelationalAttribute, isMediaAttribute } from './content-types'; +import { isRelationalAttribute, isMediaAttribute, isComponentAttribute } from './content-types'; export type VisitorUtils = ReturnType; @@ -154,7 +154,7 @@ const traverseEntity = async (visitor: Visitor, options: TraverseOptions, entity continue; } - if (attribute.type === 'component') { + if (isComponentAttribute(attribute)) { const targetSchema = strapi.getModel(attribute.component); if (isArray(value)) { diff --git a/packages/core/utils/src/traverse/factory.ts b/packages/core/utils/src/traverse/factory.ts index 8acb564eb6..d696cad90e 100644 --- a/packages/core/utils/src/traverse/factory.ts +++ b/packages/core/utils/src/traverse/factory.ts @@ -1,6 +1,12 @@ /* eslint-disable @typescript-eslint/no-loop-func */ import { isNil, pick } from 'lodash/fp'; -import { Attribute, Model, RelationalAttribute } from '../types'; +import { + Attribute, + ComponentAttribute, + DynamicZoneAttribute, + Model, + RelationalAttribute, +} from '../types'; export interface Path { raw: string | null; @@ -89,6 +95,8 @@ interface State { }; } +const DEFAULT_PATH = { raw: null, attribute: null }; + export default () => { const state: State = { parsers: [], @@ -100,8 +108,8 @@ export default () => { }, }; - const traverse: Traverse = async (visitor, options: TraverseOptions, data) => { - const { path = { raw: null, attribute: null }, schema } = options ?? {}; + const traverse: Traverse = async (visitor, options, data) => { + const { path = DEFAULT_PATH, schema } = options ?? {}; // interceptors for (const { predicate, handler } of state.interceptors) { @@ -233,11 +241,11 @@ export default () => { return this.onAttribute(({ attribute }) => attribute?.type === 'media', handler); }, - onComponent(handler: AttributeHandler['handler']) { + onComponent(handler: AttributeHandler['handler']) { return this.onAttribute(({ attribute }) => attribute?.type === 'component', handler); }, - onDynamicZone(handler: AttributeHandler['handler']) { + onDynamicZone(handler: AttributeHandler['handler']) { return this.onAttribute(({ attribute }) => attribute?.type === 'dynamiczone', handler); }, }; diff --git a/packages/core/utils/src/traverse/query-filters.ts b/packages/core/utils/src/traverse/query-filters.ts index c4ee44d948..e76df03d8f 100644 --- a/packages/core/utils/src/traverse/query-filters.ts +++ b/packages/core/utils/src/traverse/query-filters.ts @@ -1,8 +1,8 @@ -import { curry, isObject, isEmpty, isArray, isNil, cloneDeep, omit, prop } from 'lodash/fp'; +import { curry, isObject, isEmpty, isArray, isNil, cloneDeep, omit } from 'lodash/fp'; import traverseFactory from './factory'; -import { VisitorOptions } from '../../traverse-entity'; +const isObj = (value: unknown): value is Record => isObject(value); const filters = traverseFactory() .intercept( @@ -13,7 +13,9 @@ const filters = traverseFactory() filters.map((filter, i) => { // In filters, only operators such as $and, $in, $notIn or $or and implicit operators like [...] // can have a value array, thus we can update the raw path but not the attribute one - const newPath = { ...options.path, raw: `${options.path.raw}[${i}]` }; + const newPath = options.path + ? { ...options.path, raw: `${options.path.raw}[${i}]` } + : options.path; return recurse(visitor, { ...options, path: newPath }, filter); }) @@ -23,34 +25,31 @@ const filters = traverseFactory() ) .intercept( // Ignore non object filters and return the value as-is - (filters) => !isObject(filters), + (filters): filters is unknown => !isObject(filters), (_, __, filters) => { return filters; } ) // Parse object values - .parse( - (value) => typeof value === 'object', - () => ({ - transform: cloneDeep, + .parse(isObj, () => ({ + transform: cloneDeep, - remove(key, data) { - return omit(key, data); - }, + remove(key, data) { + return omit(key, data); + }, - set(key, value, data) { - return { ...data, [key]: value }; - }, + set(key, value, data) { + return { ...data, [key]: value }; + }, - keys(data) { - return Object.keys(data); - }, + keys(data) { + return Object.keys(data); + }, - get(key, data) { - return prop(key, data); - }, - }) - ) + get(key, data) { + return data[key]; + }, + })) // Ignore null or undefined values .ignore(({ value }) => isNil(value)) // Recursion on operators (non attributes) diff --git a/packages/core/utils/src/traverse/query-populate.ts b/packages/core/utils/src/traverse/query-populate.ts index f4faaab221..489b2a0e20 100644 --- a/packages/core/utils/src/traverse/query-populate.ts +++ b/packages/core/utils/src/traverse/query-populate.ts @@ -11,10 +11,12 @@ import { cloneDeep, join, first, + omit, } from 'lodash/fp'; import traverseFactory from './factory'; import { Attribute } from '../types'; +import { isMorphToRelationalAttribute } from '../content-types'; const isKeyword = (keyword: string) => { return ({ key, attribute }: { key: string; attribute: Attribute }) => { @@ -25,6 +27,8 @@ const isKeyword = (keyword: string) => { const isStringArray = (value: unknown): value is string[] => isArray(value) && value.every(isString); +const isObj = (value: unknown): value is Record => isObject(value); + const populate = traverseFactory() // Array of strings ['foo', 'foo.bar'] => map(recurse), then filter out empty items .intercept(isStringArray, async (visitor, options, populate, { recurse }) => { @@ -57,7 +61,8 @@ const populate = traverseFactory() }, keys(data) { - return [first(tokenize(data))]; + const v = first(tokenize(data)); + return v ? [v] : []; }, get(key, data) { @@ -68,7 +73,7 @@ const populate = traverseFactory() }; }) // Parse object values - .parse(isObject, () => ({ + .parse(isObj, () => ({ transform: cloneDeep, remove(key, data) { @@ -102,7 +107,11 @@ const populate = traverseFactory() } ) .on(isKeyword('on'), async ({ key, visitor, path, value }, { set, recurse }) => { - const newOn = {}; + const newOn: Record = {}; + + if (!isObj(value)) { + return; + } for (const [uid, subPopulate] of Object.entries(value)) { const model = strapi.getModel(uid); @@ -117,16 +126,14 @@ const populate = traverseFactory() }) // Handle populate on relation .onRelation(async ({ key, value, attribute, visitor, path, schema }, { set, recurse }) => { - const isMorphRelation = attribute.relation.toLowerCase().startsWith('morph'); - - if (isMorphRelation) { + if (isMorphToRelationalAttribute(attribute)) { // Don't traverse values that cannot be parsed - if (!isObject(value) || !isObject(value?.on)) { + if (!isObject(value) || !('on' in value && isObject(value?.on))) { return; } // If there is a populate fragment defined, traverse it - const newValue = await recurse(visitor, { schema, path }, { on: value.on }); + const newValue = await recurse(visitor, { schema, path }, { on: value?.on }); set(key, { on: newValue }); } @@ -159,12 +166,11 @@ const populate = traverseFactory() .onDynamicZone(async ({ key, value, attribute, schema, visitor, path }, { set, recurse }) => { if (isObject(value)) { const { components } = attribute; - const { on, ...properties } = value; const newValue = {}; // Handle legacy DZ params - let newProperties = properties; + let newProperties: unknown = omit('on', value); for (const componentUID of components) { const componentSchema = strapi.getModel(componentUID); @@ -174,8 +180,8 @@ const populate = traverseFactory() Object.assign(newValue, newProperties); // Handle new morph fragment syntax - if (on) { - const newOn = await recurse(visitor, { schema, path }, { on }); + if ('on' in value && value.on) { + const newOn = await recurse(visitor, { schema, path }, { on: value.on }); // Recompose both syntaxes Object.assign(newValue, newOn); diff --git a/packages/core/utils/src/traverse/query-sort.ts b/packages/core/utils/src/traverse/query-sort.ts index 4dba6391aa..f95a3f6832 100644 --- a/packages/core/utils/src/traverse/query-sort.ts +++ b/packages/core/utils/src/traverse/query-sort.ts @@ -26,6 +26,8 @@ const isObjectArray = (value: unknown): value is object[] => const isNestedSorts = (value: unknown): value is string => isString(value) && value.split(',').length > 1; +const isObj = (value: unknown): value is Record => isObject(value); + const sort = traverseFactory() .intercept( // String with chained sorts (foo,bar,foobar) => split, map(recurse), then recompose @@ -98,7 +100,8 @@ const sort = traverseFactory() }, keys(data) { - return [first(tokenize(data))]; + const v = first(tokenize(data)); + return v ? [v] : []; }, get(key, data) { @@ -109,7 +112,7 @@ const sort = traverseFactory() }; }) // Parse object values - .parse(isObject, () => ({ + .parse(isObj, () => ({ transform: cloneDeep, remove(key, data) { diff --git a/packages/core/utils/src/types.ts b/packages/core/utils/src/types.ts index f7bdf3fb35..e093a71ff8 100644 --- a/packages/core/utils/src/types.ts +++ b/packages/core/utils/src/types.ts @@ -14,6 +14,14 @@ export interface Attribute { export interface RelationalAttribute extends Attribute { relation: string; + target: string; +} +export interface ComponentAttribute extends Attribute { + component: string; + repeatable?: boolean; +} +export interface DynamicZoneAttribute extends Attribute { + components: string[]; } export type Kind = 'singleType' | 'collectionType'; diff --git a/packages/core/utils/src/validators.ts b/packages/core/utils/src/validators.ts index 1b67445a42..d1e3d270a2 100644 --- a/packages/core/utils/src/validators.ts +++ b/packages/core/utils/src/validators.ts @@ -1,7 +1,7 @@ /* eslint-disable no-template-curly-in-string */ import * as yup from 'yup'; import _ from 'lodash'; -import { defaults } from 'lodash/fp'; +import { defaults, isNumber, isInteger } from 'lodash/fp'; import * as utils from './string-formatting'; import { YupValidationError } from './errors'; import printValue from './print-value'; @@ -65,46 +65,64 @@ class StrapiIDSchema extends MixedSchemaType { super({ type: 'strapiID' }); } - _typeCheck(value) { - return typeof value === 'string' || (Number.isInteger(value) && value >= 0); + _typeCheck(value: unknown): value is string | number { + return typeof value === 'string' || (isNumber(value) && isInteger(value) && value >= 0); } } +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore yup.strapiID = () => new StrapiIDSchema(); -const handleYupError = (error, errorMessage) => { +const handleYupError = (error: yup.ValidationError, errorMessage: string) => { throw new YupValidationError(error, errorMessage); }; const defaultValidationParam = { strict: true, abortEarly: false }; const validateYupSchema = - (schema, options = {}) => - async (body, errorMessage) => { + (schema: yup.AnySchema, options = {}) => + async (body: unknown, errorMessage: string) => { try { const optionsWithDefaults = defaults(defaultValidationParam, options); const result = await schema.validate(body, optionsWithDefaults); return result; } catch (e) { - handleYupError(e, errorMessage); + if (e instanceof yup.ValidationError) { + handleYupError(e, errorMessage); + } + + throw e; } }; const validateYupSchemaSync = - (schema, options = {}) => - (body, errorMessage) => { + (schema: yup.AnySchema, options = {}) => + (body: unknown, errorMessage: string) => { try { const optionsWithDefaults = defaults(defaultValidationParam, options); return schema.validateSync(body, optionsWithDefaults); } catch (e) { - handleYupError(e, errorMessage); + if (e instanceof yup.ValidationError) { + handleYupError(e, errorMessage); + } + + throw e; } }; +interface NoTypeOptions { + path: string; + type: string; + value: unknown; + originalValue: unknown; +} + // Temporary fix of this issue : https://github.com/jquense/yup/issues/616 yup.setLocale({ mixed: { - notType({ path, type, value, originalValue }) { + notType(options: NoTypeOptions) { + const { path, type, value, originalValue } = options; const isCast = originalValue != null && originalValue !== value; const msg = `${path} must be a \`${type}\` type, ` +