This commit is contained in:
Alexandre Bodin 2023-06-05 14:01:39 +02:00
parent f108e049e8
commit ae0850295d
19 changed files with 288 additions and 136 deletions

View File

@ -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');
};

View File

@ -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<string, unknown> => _.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<string, unknown>): 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,

View File

@ -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;

View File

@ -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<Data, Promise<Data>>;
}
export interface SanitizeFunc {
(data: unknown, schema: Model, options?: Options): Promise<unknown>;
}
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<string, unknown>,
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) {

View File

@ -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 }) => {

View File

@ -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 }) => {

View File

@ -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)) {

View File

@ -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)) {

View File

@ -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') {

View File

@ -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) {

View File

@ -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<string, unknown>[] = [];
for (const element of data[key]) {
for (const element of (data as Record<string, MorphArray>)[key]) {
const scopes = ACTIONS_TO_VERIFY.map((action) => `${element.__type}.${action}`);
const isAllowed = await hasAccessToSomeScopes(scopes, auth);

View File

@ -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 }) => {

View File

@ -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<typeof createVisitorUtils>;
@ -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)) {

View File

@ -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<RelationalAttribute>['handler']) {
onComponent(handler: AttributeHandler<ComponentAttribute>['handler']) {
return this.onAttribute(({ attribute }) => attribute?.type === 'component', handler);
},
onDynamicZone(handler: AttributeHandler<RelationalAttribute>['handler']) {
onDynamicZone(handler: AttributeHandler<DynamicZoneAttribute>['handler']) {
return this.onAttribute(({ attribute }) => attribute?.type === 'dynamiczone', handler);
},
};

View File

@ -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<string, unknown> => 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)

View File

@ -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<string, unknown> => 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<string, unknown> = {};
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);

View File

@ -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<string, unknown> => 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) {

View File

@ -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';

View File

@ -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, ` +