mirror of
https://github.com/strapi/strapi.git
synced 2025-08-31 12:23:05 +00:00
feat!: document service query param validation (#21034)
This commit is contained in:
parent
1204e14d29
commit
fbbccd06ea
@ -1,7 +1,8 @@
|
||||
import { omit, assoc, merge, curry } from 'lodash/fp';
|
||||
|
||||
import { async, contentTypes as contentTypesUtils } from '@strapi/utils';
|
||||
import { async, contentTypes as contentTypesUtils, validate } from '@strapi/utils';
|
||||
|
||||
import { UID } from '@strapi/types';
|
||||
import { wrapInTransaction, type RepositoryFactoryMethod } from './common';
|
||||
import * as DP from './draft-and-publish';
|
||||
import * as i18n from './internationalization';
|
||||
@ -15,10 +16,38 @@ import { transformParamsToQuery } from './transform/query';
|
||||
import { transformParamsDocumentId } from './transform/id-transform';
|
||||
import { createEventManager } from './events';
|
||||
|
||||
const { validators } = validate;
|
||||
|
||||
// we have to typecast to reconcile the differences between validator and database getModel
|
||||
const getModel = ((schema: UID.Schema) => strapi.getModel(schema)) as (schema: string) => any;
|
||||
|
||||
export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
|
||||
const contentType = strapi.contentType(uid);
|
||||
const hasDraftAndPublish = contentTypesUtils.hasDraftAndPublish(contentType);
|
||||
|
||||
// Define the validations that should be performed
|
||||
const sortValidations = ['nonAttributesOperators', 'dynamicZones', 'morphRelations'];
|
||||
const fieldValidations = ['scalarAttributes'];
|
||||
const filtersValidations = ['nonAttributesOperators', 'dynamicZones', 'morphRelations'];
|
||||
const populateValidations = {
|
||||
sort: sortValidations,
|
||||
field: fieldValidations,
|
||||
filters: filtersValidations,
|
||||
populate: ['nonAttributesOperators'],
|
||||
};
|
||||
|
||||
const validateParams = async (params: any) => {
|
||||
const ctx = { schema: contentType, getModel };
|
||||
await validators.validateFilters(ctx, params.filters, filtersValidations);
|
||||
await validators.validateSort(ctx, params.sort, sortValidations);
|
||||
await validators.validateFields(ctx, params.fields, fieldValidations);
|
||||
await validators.validatePopulate(ctx, params.populate, populateValidations);
|
||||
|
||||
// TODO: add validate status, locale, pagination
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
const entries = createEntriesService(uid);
|
||||
|
||||
const eventManager = createEventManager(strapi, uid);
|
||||
@ -26,6 +55,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
|
||||
|
||||
async function findMany(params = {} as any) {
|
||||
const query = await async.pipe(
|
||||
validateParams,
|
||||
DP.defaultToDraft,
|
||||
DP.statusToLookup(contentType),
|
||||
i18n.defaultLocale(contentType),
|
||||
@ -39,6 +69,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
|
||||
|
||||
async function findFirst(params = {} as any) {
|
||||
const query = await async.pipe(
|
||||
validateParams,
|
||||
DP.defaultToDraft,
|
||||
DP.statusToLookup(contentType),
|
||||
i18n.defaultLocale(contentType),
|
||||
@ -55,6 +86,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
|
||||
const { documentId, ...params } = opts;
|
||||
|
||||
const query = await async.pipe(
|
||||
validateParams,
|
||||
DP.defaultToDraft,
|
||||
DP.statusToLookup(contentType),
|
||||
i18n.defaultLocale(contentType),
|
||||
@ -71,6 +103,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
|
||||
const { documentId, ...params } = opts;
|
||||
|
||||
const query = await async.pipe(
|
||||
validateParams,
|
||||
omit('status'),
|
||||
i18n.defaultLocale(contentType),
|
||||
i18n.multiLocaleToLookup(contentType),
|
||||
@ -98,6 +131,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
|
||||
const { documentId, ...params } = opts;
|
||||
|
||||
const queryParams = await async.pipe(
|
||||
validateParams,
|
||||
DP.filterDataPublishedAt,
|
||||
DP.setStatusToDraft(contentType),
|
||||
DP.statusToData(contentType),
|
||||
@ -123,6 +157,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
|
||||
const { documentId, ...params } = opts;
|
||||
|
||||
const queryParams = await async.pipe(
|
||||
validateParams,
|
||||
DP.filterDataPublishedAt,
|
||||
i18n.defaultLocale(contentType),
|
||||
i18n.multiLocaleToLookup(contentType)
|
||||
@ -143,6 +178,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
|
||||
const clonedEntries = await async.map(
|
||||
entriesToClone,
|
||||
async.pipe(
|
||||
validateParams,
|
||||
omit('id'),
|
||||
// assign new documentId
|
||||
assoc('documentId', createDocumentId()),
|
||||
@ -161,6 +197,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
|
||||
const { documentId, ...params } = opts;
|
||||
|
||||
const queryParams = await async.pipe(
|
||||
validateParams,
|
||||
DP.filterDataPublishedAt,
|
||||
DP.setStatusToDraft(contentType),
|
||||
DP.statusToLookup(contentType),
|
||||
@ -212,6 +249,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
|
||||
|
||||
async function count(params = {} as any) {
|
||||
const query = await async.pipe(
|
||||
validateParams,
|
||||
DP.defaultStatus(contentType),
|
||||
DP.statusToLookup(contentType),
|
||||
i18n.defaultLocale(contentType),
|
||||
@ -226,6 +264,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
|
||||
const { documentId, ...params } = opts;
|
||||
|
||||
const queryParams = await async.pipe(
|
||||
validateParams,
|
||||
i18n.defaultLocale(contentType),
|
||||
i18n.multiLocaleToLookup(contentType)
|
||||
)(params);
|
||||
@ -266,6 +305,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
|
||||
const { documentId, ...params } = opts;
|
||||
|
||||
const query = await async.pipe(
|
||||
validateParams,
|
||||
i18n.defaultLocale(contentType),
|
||||
i18n.multiLocaleToLookup(contentType),
|
||||
transformParamsToQuery(uid),
|
||||
@ -284,6 +324,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
|
||||
const { documentId, ...params } = opts;
|
||||
|
||||
const queryParams = await async.pipe(
|
||||
validateParams,
|
||||
i18n.defaultLocale(contentType),
|
||||
i18n.multiLocaleToLookup(contentType)
|
||||
)(params);
|
||||
|
@ -79,7 +79,7 @@ interface CommonHandler<AttributeType = Attribute> {
|
||||
|
||||
export interface TransformUtils {
|
||||
remove(key: string): void;
|
||||
set(key: string, valeu: unknown): void;
|
||||
set(key: string, value: unknown): void;
|
||||
recurse: Traverse;
|
||||
}
|
||||
|
||||
|
@ -2,14 +2,24 @@ import { curry, isArray, isString, eq, trim, constant } from 'lodash/fp';
|
||||
|
||||
import traverseFactory from './factory';
|
||||
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
isArray(value) && value.every(isString);
|
||||
const isStringArray = (value: unknown): value is string[] => {
|
||||
return isArray(value) && value.every(isString);
|
||||
};
|
||||
|
||||
const fields = traverseFactory()
|
||||
// Intercept array of strings
|
||||
// e.g. fields=['title', 'description']
|
||||
.intercept(isStringArray, async (visitor, options, fields, { recurse }) => {
|
||||
return Promise.all(fields.map((field) => recurse(visitor, options, field)));
|
||||
})
|
||||
// Intercept comma separated fields (as string)
|
||||
// e.g. fields='title,description'
|
||||
.intercept(
|
||||
(value): value is string => isString(value) && value.includes(','),
|
||||
(visitor, options, fields, { recurse }) => {
|
||||
return Promise.all(fields.split(',').map((field) => recurse(visitor, options, field)));
|
||||
}
|
||||
)
|
||||
// Return wildcards as is
|
||||
.intercept((value): value is string => eq('*', value), constant('*'))
|
||||
// Parse string values
|
||||
|
@ -132,6 +132,8 @@ const populate = traverseFactory()
|
||||
},
|
||||
}))
|
||||
.ignore(({ key, attribute }) => {
|
||||
// we don't want to recurse using traversePopulate and instead let
|
||||
// the visitors recurse with the appropriate traversal (sort, filters, etc...)
|
||||
return ['sort', 'filters', 'fields'].includes(key) && !attribute;
|
||||
})
|
||||
.on(
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { ValidationError } from '../errors';
|
||||
|
||||
export const throwInvalidKey = ({ key, path }: { key: string; path?: string | null }) => {
|
||||
export const throwInvalidKey = ({ key, path }: { key: string; path?: string | null }): never => {
|
||||
const msg = path && path !== key ? `Invalid key ${key} at ${path}` : `Invalid key ${key}`;
|
||||
|
||||
throw new ValidationError(msg, {
|
||||
@ -8,3 +8,17 @@ export const throwInvalidKey = ({ key, path }: { key: string; path?: string | nu
|
||||
path,
|
||||
});
|
||||
};
|
||||
|
||||
// lodash/fp curry does not detect async methods, so we'll use our own that is typed correctly
|
||||
export const asyncCurry = <A extends unknown[], R>(
|
||||
fn: (...args: A) => Promise<R>
|
||||
): ((...args: Partial<A>) => any) => {
|
||||
const curried = (...args: unknown[]): unknown => {
|
||||
if (args.length >= fn.length) {
|
||||
return fn(...(args as A));
|
||||
}
|
||||
return (...moreArgs: unknown[]) => curried(...args, ...moreArgs);
|
||||
};
|
||||
|
||||
return curried;
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { curry, isEmpty, isNil } from 'lodash/fp';
|
||||
import { isEmpty, isNil, isObject } from 'lodash/fp';
|
||||
|
||||
import { pipe as pipeAsync } from '../async';
|
||||
import traverseEntity from '../traverse-entity';
|
||||
@ -11,8 +11,9 @@ import {
|
||||
} from '../traverse';
|
||||
import { throwPassword, throwPrivate, throwDynamicZones, throwMorphToRelations } from './visitors';
|
||||
import { isOperator } from '../operators';
|
||||
import { throwInvalidKey } from './utils';
|
||||
import { asyncCurry, throwInvalidKey } from './utils';
|
||||
import type { Model, Data } from '../types';
|
||||
import parseType from '../parse-type';
|
||||
|
||||
const { ID_ATTRIBUTE, DOC_ID_ATTRIBUTE } = constants;
|
||||
|
||||
@ -21,7 +22,7 @@ interface Context {
|
||||
getModel: (model: string) => Model;
|
||||
}
|
||||
|
||||
const throwPasswords = (ctx: Context) => async (entity: Data) => {
|
||||
export const throwPasswords = (ctx: Context) => async (entity: Data) => {
|
||||
if (!ctx.schema) {
|
||||
throw new Error('Missing schema in throwPasswords');
|
||||
}
|
||||
@ -29,176 +30,386 @@ const throwPasswords = (ctx: Context) => async (entity: Data) => {
|
||||
return traverseEntity(throwPassword, ctx, entity);
|
||||
};
|
||||
|
||||
const defaultValidateFilters = curry((ctx: Context, filters: unknown) => {
|
||||
// TODO: schema checks should check that it is a validate schema with yup
|
||||
if (!ctx.schema) {
|
||||
throw new Error('Missing schema in defaultValidateFilters');
|
||||
}
|
||||
type AnyFunc = (...args: any[]) => any;
|
||||
|
||||
export const FILTER_TRAVERSALS = [
|
||||
'nonAttributesOperators',
|
||||
'dynamicZones',
|
||||
'morphRelations',
|
||||
'passwords',
|
||||
'private',
|
||||
];
|
||||
|
||||
export const validateFilters = asyncCurry(
|
||||
async (ctx: Context, filters: unknown, include: (typeof FILTER_TRAVERSALS)[number][]) => {
|
||||
// TODO: schema checks should check that it is a valid schema with yup
|
||||
if (!ctx.schema) {
|
||||
throw new Error('Missing schema in defaultValidateFilters');
|
||||
}
|
||||
|
||||
// Build the list of functions conditionally
|
||||
const functionsToApply: Array<AnyFunc> = [];
|
||||
|
||||
return pipeAsync(
|
||||
// keys that are not attributes or valid operators
|
||||
traverseQueryFilters(({ key, attribute, path }) => {
|
||||
// ID is not an attribute per se, so we need to make
|
||||
// an extra check to ensure we're not removing it
|
||||
if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) {
|
||||
return;
|
||||
}
|
||||
if (include.includes('nonAttributesOperators')) {
|
||||
functionsToApply.push(
|
||||
traverseQueryFilters(({ key, attribute, path }) => {
|
||||
// ID is not an attribute per se, so we need to make
|
||||
// an extra check to ensure we're not removing it
|
||||
if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isAttribute = !!attribute;
|
||||
const isAttribute = !!attribute;
|
||||
|
||||
if (!isAttribute && !isOperator(key)) {
|
||||
throwInvalidKey({ key, path: path.attribute });
|
||||
}
|
||||
}, ctx),
|
||||
// dynamic zones from filters
|
||||
traverseQueryFilters(throwDynamicZones, ctx),
|
||||
// morphTo relations from filters; because you can't have deep filtering on morph relations
|
||||
traverseQueryFilters(throwMorphToRelations, ctx),
|
||||
// passwords from filters
|
||||
traverseQueryFilters(throwPassword, ctx),
|
||||
// private from filters
|
||||
traverseQueryFilters(throwPrivate, ctx)
|
||||
// we allow empty objects to validate and only sanitize them out, so that users may write "lazy" queries without checking their params exist
|
||||
)(filters);
|
||||
if (!isAttribute && !isOperator(key)) {
|
||||
throwInvalidKey({ key, path: path.attribute });
|
||||
}
|
||||
}, ctx)
|
||||
);
|
||||
}
|
||||
|
||||
if (include.includes('dynamicZones')) {
|
||||
functionsToApply.push(traverseQueryFilters(throwDynamicZones, ctx));
|
||||
}
|
||||
|
||||
if (include.includes('morphRelations')) {
|
||||
functionsToApply.push(traverseQueryFilters(throwMorphToRelations, ctx));
|
||||
}
|
||||
|
||||
if (include.includes('passwords')) {
|
||||
functionsToApply.push(traverseQueryFilters(throwPassword, ctx));
|
||||
}
|
||||
|
||||
if (include.includes('private')) {
|
||||
functionsToApply.push(traverseQueryFilters(throwPrivate, ctx));
|
||||
}
|
||||
|
||||
// Return directly if no validation functions are provided
|
||||
if (functionsToApply.length === 0) {
|
||||
return filters;
|
||||
}
|
||||
|
||||
return pipeAsync(...functionsToApply)(filters);
|
||||
}
|
||||
);
|
||||
|
||||
export const defaultValidateFilters = asyncCurry(async (ctx: Context, filters: unknown) => {
|
||||
return validateFilters(ctx, filters, FILTER_TRAVERSALS);
|
||||
});
|
||||
|
||||
const defaultValidateSort = curry((ctx: Context, sort: unknown) => {
|
||||
if (!ctx.schema) {
|
||||
throw new Error('Missing schema in defaultValidateSort');
|
||||
export const SORT_TRAVERSALS = [
|
||||
'nonAttributesOperators',
|
||||
'dynamicZones',
|
||||
'morphRelations',
|
||||
'passwords',
|
||||
'private',
|
||||
'nonScalarEmptyKeys',
|
||||
];
|
||||
|
||||
export const validateSort = asyncCurry(
|
||||
async (ctx: Context, sort: unknown, include: (typeof SORT_TRAVERSALS)[number][]) => {
|
||||
if (!ctx.schema) {
|
||||
throw new Error('Missing schema in defaultValidateSort');
|
||||
}
|
||||
|
||||
// Build the list of functions conditionally based on the include array
|
||||
const functionsToApply: Array<AnyFunc> = [];
|
||||
|
||||
// Validate non attribute keys
|
||||
if (include.includes('nonAttributesOperators')) {
|
||||
functionsToApply.push(
|
||||
traverseQuerySort(({ key, attribute, path }) => {
|
||||
// ID is not an attribute per se, so we need to make
|
||||
// an extra check to ensure we're not removing it
|
||||
if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!attribute) {
|
||||
throwInvalidKey({ key, path: path.attribute });
|
||||
}
|
||||
}, ctx)
|
||||
);
|
||||
}
|
||||
|
||||
// Validate dynamic zones from sort
|
||||
if (include.includes('dynamicZones')) {
|
||||
functionsToApply.push(traverseQuerySort(throwDynamicZones, ctx));
|
||||
}
|
||||
|
||||
// Validate morphTo relations from sort
|
||||
if (include.includes('morphRelations')) {
|
||||
functionsToApply.push(traverseQuerySort(throwMorphToRelations, ctx));
|
||||
}
|
||||
|
||||
// Validate passwords from sort
|
||||
if (include.includes('passwords')) {
|
||||
functionsToApply.push(traverseQuerySort(throwPassword, ctx));
|
||||
}
|
||||
|
||||
// Validate private from sort
|
||||
if (include.includes('private')) {
|
||||
functionsToApply.push(traverseQuerySort(throwPrivate, ctx));
|
||||
}
|
||||
|
||||
// Validate non-scalar empty keys
|
||||
if (include.includes('nonScalarEmptyKeys')) {
|
||||
functionsToApply.push(
|
||||
traverseQuerySort(({ key, attribute, value, path }) => {
|
||||
// ID is not an attribute per se, so we need to make
|
||||
// an extra check to ensure we're not removing it
|
||||
if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isScalarAttribute(attribute) && isEmpty(value)) {
|
||||
throwInvalidKey({ key, path: path.attribute });
|
||||
}
|
||||
}, ctx)
|
||||
);
|
||||
}
|
||||
|
||||
// Return directly if no validation functions are provided
|
||||
if (functionsToApply.length === 0) {
|
||||
return sort;
|
||||
}
|
||||
|
||||
return pipeAsync(...functionsToApply)(sort);
|
||||
}
|
||||
);
|
||||
|
||||
return pipeAsync(
|
||||
// non attribute keys
|
||||
traverseQuerySort(({ key, attribute, path }) => {
|
||||
// ID is not an attribute per se, so we need to make
|
||||
// an extra check to ensure we're not removing it
|
||||
if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!attribute) {
|
||||
throwInvalidKey({ key, path: path.attribute });
|
||||
}
|
||||
}, ctx),
|
||||
// dynamic zones from sort
|
||||
traverseQuerySort(throwDynamicZones, ctx),
|
||||
// morphTo relations from sort
|
||||
traverseQuerySort(throwMorphToRelations, ctx),
|
||||
// private from sort
|
||||
traverseQuerySort(throwPrivate, ctx),
|
||||
// passwords from filters
|
||||
traverseQuerySort(throwPassword, ctx),
|
||||
// keys for empty non-scalar values
|
||||
traverseQuerySort(({ key, attribute, value, path }) => {
|
||||
// ID is not an attribute per se, so we need to make
|
||||
// an extra check to ensure we're not removing it
|
||||
if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isScalarAttribute(attribute) && isEmpty(value)) {
|
||||
throwInvalidKey({ key, path: path.attribute });
|
||||
}
|
||||
}, ctx)
|
||||
)(sort);
|
||||
export const defaultValidateSort = asyncCurry(async (ctx: Context, sort: unknown) => {
|
||||
return validateSort(ctx, sort, SORT_TRAVERSALS);
|
||||
});
|
||||
|
||||
const defaultValidateFields = curry((ctx: Context, fields: unknown) => {
|
||||
if (!ctx.schema) {
|
||||
throw new Error('Missing schema in defaultValidateFields');
|
||||
}
|
||||
export const FIELDS_TRAVERSALS = ['scalarAttributes', 'privateFields', 'passwordFields'];
|
||||
|
||||
export const validateFields = asyncCurry(
|
||||
async (ctx: Context, fields: unknown, include: (typeof FIELDS_TRAVERSALS)[number][]) => {
|
||||
if (!ctx.schema) {
|
||||
throw new Error('Missing schema in defaultValidateFields');
|
||||
}
|
||||
// Build the list of functions conditionally based on the include array
|
||||
const functionsToApply: Array<AnyFunc> = [];
|
||||
|
||||
return pipeAsync(
|
||||
// Only allow scalar attributes
|
||||
traverseQueryFields(({ key, attribute, path }) => {
|
||||
// ID is not an attribute per se, so we need to make
|
||||
// an extra check to ensure we're not throwing because of it
|
||||
if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) {
|
||||
return;
|
||||
}
|
||||
if (include.includes('scalarAttributes')) {
|
||||
functionsToApply.push(
|
||||
traverseQueryFields(({ key, attribute, path }) => {
|
||||
// ID is not an attribute per se, so we need to make
|
||||
// an extra check to ensure we're not throwing because of it
|
||||
if ([ID_ATTRIBUTE, DOC_ID_ATTRIBUTE].includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNil(attribute) || !isScalarAttribute(attribute)) {
|
||||
throwInvalidKey({ key, path: path.attribute });
|
||||
}
|
||||
}, ctx),
|
||||
// private fields
|
||||
traverseQueryFields(throwPrivate, ctx),
|
||||
// password fields
|
||||
traverseQueryFields(throwPassword, ctx)
|
||||
)(fields);
|
||||
if (isNil(attribute) || !isScalarAttribute(attribute)) {
|
||||
throwInvalidKey({ key, path: path.attribute });
|
||||
}
|
||||
}, ctx)
|
||||
);
|
||||
}
|
||||
|
||||
// Private fields
|
||||
if (include.includes('privateFields')) {
|
||||
functionsToApply.push(traverseQueryFields(throwPrivate, ctx));
|
||||
}
|
||||
|
||||
// Password fields
|
||||
if (include.includes('passwordFields')) {
|
||||
functionsToApply.push(traverseQueryFields(throwPassword, ctx));
|
||||
}
|
||||
|
||||
// Return directly if no validation functions are provided
|
||||
if (functionsToApply.length === 0) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
return pipeAsync(...functionsToApply)(fields);
|
||||
}
|
||||
);
|
||||
|
||||
export const defaultValidateFields = asyncCurry(async (ctx: Context, fields: unknown) => {
|
||||
return validateFields(ctx, fields, FIELDS_TRAVERSALS);
|
||||
});
|
||||
|
||||
const defaultValidatePopulate = curry((ctx: Context, populate: unknown) => {
|
||||
export const POPULATE_TRAVERSALS = ['nonAttributesOperators', 'private'];
|
||||
|
||||
export const validatePopulate = asyncCurry(
|
||||
async (
|
||||
ctx: Context,
|
||||
populate: unknown,
|
||||
includes: {
|
||||
fields?: (typeof FIELDS_TRAVERSALS)[number][];
|
||||
sort?: (typeof SORT_TRAVERSALS)[number][];
|
||||
filters?: (typeof FILTER_TRAVERSALS)[number][];
|
||||
populate?: (typeof POPULATE_TRAVERSALS)[number][];
|
||||
}
|
||||
) => {
|
||||
if (!ctx.schema) {
|
||||
throw new Error('Missing schema in defaultValidatePopulate');
|
||||
}
|
||||
// Build the list of functions conditionally based on the include array
|
||||
const functionsToApply: Array<AnyFunc> = [];
|
||||
|
||||
// Always include the main traversal function
|
||||
functionsToApply.push(
|
||||
traverseQueryPopulate(async ({ key, path, value, schema, attribute, getModel }, { set }) => {
|
||||
if (attribute) {
|
||||
const isPopulatableAttribute = ['relation', 'dynamiczone', 'component', 'media'].includes(
|
||||
attribute.type
|
||||
);
|
||||
|
||||
// Throw on non-populate attributes
|
||||
if (!isPopulatableAttribute) {
|
||||
throwInvalidKey({ key, path: path.raw });
|
||||
}
|
||||
|
||||
// Valid populatable attribute, so return
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're looking at a populate fragment, ensure its target is valid
|
||||
if (key === 'on') {
|
||||
// Populate fragment should always be an object
|
||||
if (!isObject(value)) {
|
||||
return throwInvalidKey({ key, path: path.raw });
|
||||
}
|
||||
|
||||
const targets = Object.keys(value);
|
||||
|
||||
for (const target of targets) {
|
||||
const model = getModel(target);
|
||||
|
||||
// If a target is invalid (no matching model), then raise an error
|
||||
if (!model) {
|
||||
throwInvalidKey({ key: target, path: `${path.raw}.${target}` });
|
||||
}
|
||||
}
|
||||
|
||||
// If the fragment's target is fine, then let it pass
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore plain wildcards
|
||||
if (key === '' && value === '*') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure count is a boolean
|
||||
if (key === 'count') {
|
||||
try {
|
||||
parseType({ type: 'boolean', value });
|
||||
return;
|
||||
} catch {
|
||||
throwInvalidKey({ key, path: path.attribute });
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed boolean-like keywords should be ignored
|
||||
try {
|
||||
parseType({ type: 'boolean', value: key });
|
||||
// Key is an allowed boolean-like keyword, skipping validation...
|
||||
return;
|
||||
} catch {
|
||||
// Continue, because it's not a boolean-like
|
||||
}
|
||||
|
||||
// Handle nested `sort` validation with custom or default traversals
|
||||
if (key === 'sort') {
|
||||
set(
|
||||
key,
|
||||
await validateSort(
|
||||
{
|
||||
schema,
|
||||
getModel,
|
||||
},
|
||||
value, // pass the sort value
|
||||
includes?.sort || SORT_TRAVERSALS
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle nested `filters` validation with custom or default traversals
|
||||
if (key === 'filters') {
|
||||
set(
|
||||
key,
|
||||
await validateFilters(
|
||||
{
|
||||
schema,
|
||||
getModel,
|
||||
},
|
||||
value, // pass the filters value
|
||||
includes?.filters || FILTER_TRAVERSALS
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle nested `fields` validation with custom or default traversals
|
||||
if (key === 'fields') {
|
||||
set(
|
||||
key,
|
||||
await validateFields(
|
||||
{
|
||||
schema,
|
||||
getModel,
|
||||
},
|
||||
value, // pass the fields value
|
||||
includes?.fields || FIELDS_TRAVERSALS
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle recursive nested `populate` validation with the same include object
|
||||
if (key === 'populate') {
|
||||
set(
|
||||
key,
|
||||
await validatePopulate(
|
||||
{
|
||||
schema,
|
||||
getModel,
|
||||
},
|
||||
value, // pass the nested populate value
|
||||
includes // pass down the same includes object
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Throw an error if non-attribute operators are included in the populate array
|
||||
if (includes?.populate?.includes('nonAttributesOperators')) {
|
||||
throwInvalidKey({ key, path: path.attribute });
|
||||
}
|
||||
}, ctx)
|
||||
);
|
||||
|
||||
// Conditionally traverse for private fields only if 'private' is included
|
||||
if (includes?.populate?.includes('private')) {
|
||||
functionsToApply.push(traverseQueryPopulate(throwPrivate, ctx));
|
||||
}
|
||||
|
||||
// Return directly if no validation functions are provided
|
||||
if (functionsToApply.length === 0) {
|
||||
return populate;
|
||||
}
|
||||
|
||||
return pipeAsync(...functionsToApply)(populate);
|
||||
}
|
||||
);
|
||||
|
||||
export const defaultValidatePopulate = asyncCurry(async (ctx: Context, populate: unknown) => {
|
||||
if (!ctx.schema) {
|
||||
throw new Error('Missing schema in defaultValidatePopulate');
|
||||
}
|
||||
|
||||
return pipeAsync(
|
||||
traverseQueryPopulate(async ({ key, value, schema, attribute, getModel }, { set }) => {
|
||||
if (attribute) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'sort') {
|
||||
set(
|
||||
key,
|
||||
await defaultValidateSort(
|
||||
{
|
||||
schema,
|
||||
getModel,
|
||||
},
|
||||
value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'filters') {
|
||||
set(
|
||||
key,
|
||||
await defaultValidateFilters(
|
||||
{
|
||||
schema,
|
||||
getModel,
|
||||
},
|
||||
value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'fields') {
|
||||
set(
|
||||
key,
|
||||
await defaultValidateFields(
|
||||
{
|
||||
schema,
|
||||
getModel,
|
||||
},
|
||||
value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'populate') {
|
||||
set(
|
||||
key,
|
||||
await defaultValidatePopulate(
|
||||
{
|
||||
schema,
|
||||
getModel,
|
||||
},
|
||||
value
|
||||
)
|
||||
);
|
||||
}
|
||||
}, ctx),
|
||||
// Remove private fields
|
||||
traverseQueryPopulate(throwPrivate, ctx)
|
||||
)(populate);
|
||||
// Call validatePopulate and include all validations by passing in full traversal arrays
|
||||
return validatePopulate(ctx, populate, {
|
||||
filters: FILTER_TRAVERSALS,
|
||||
sort: SORT_TRAVERSALS,
|
||||
fields: FIELDS_TRAVERSALS,
|
||||
populate: POPULATE_TRAVERSALS,
|
||||
});
|
||||
});
|
||||
export {
|
||||
throwPasswords,
|
||||
defaultValidateFilters,
|
||||
defaultValidateSort,
|
||||
defaultValidateFields,
|
||||
defaultValidatePopulate,
|
||||
};
|
||||
|
@ -551,7 +551,7 @@ const BulkLocaleAction: DocumentActionComponent = ({
|
||||
}
|
||||
}, [isDraftRelationsError, toggleNotification, formatAPIError]);
|
||||
|
||||
if (!schema?.options?.draftAndPublish ?? false) {
|
||||
if (!schema?.options?.draftAndPublish) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -37,7 +37,8 @@ const registerModelsHooks = () => {
|
||||
// Use the id and populate built from non localized fields to get the full
|
||||
// result
|
||||
let resultID;
|
||||
if (Array.isArray(result?.entries)) {
|
||||
// TODO: fix bug where an empty array can be returned
|
||||
if (Array.isArray(result?.entries) && result.entries[0]?.id) {
|
||||
resultID = result.entries[0].id;
|
||||
} else if (result?.id) {
|
||||
resultID = result.id;
|
||||
|
@ -428,6 +428,17 @@ describe('Core API - Validate', () => {
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid modifier', () => {
|
||||
it.each([
|
||||
['name_fake (asc)', { name_fake: 'asc' }, defaultDocumentsOrder],
|
||||
['name_fake (desc)', { name_fake: 'desc' }, defaultDocumentsOrder],
|
||||
])('Error with sort: %s', async (_s, sort, order) => {
|
||||
const res = await rq.get('/api/documents', { qs: { sort } });
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password', () => {
|
||||
@ -444,12 +455,12 @@ describe('Core API - Validate', () => {
|
||||
});
|
||||
|
||||
// TODO: Nested sort returns duplicate results. Add back those tests when the issue will be fixed
|
||||
describe.skip('Relation', () => {
|
||||
describe('Relation', () => {
|
||||
describe('Scalar', () => {
|
||||
describe('Basic (no modifiers)', () => {
|
||||
describe.skip('Basic (no modifiers)', () => {
|
||||
it.each([
|
||||
['relations.name (asc)', { relations: { name: 'asc' } }, [1, 3, 2]],
|
||||
['relations.sname (desc)', { relations: { name: 'desc' } }, [2, 1, 3]],
|
||||
['relations.name (desc)', { relations: { name: 'desc' } }, [2, 1, 3]],
|
||||
])('Successfully sort: %s', async (_s, sort, order) => {
|
||||
const res = await rq.get('/api/documents', { qs: { sort } });
|
||||
|
||||
@ -538,6 +549,18 @@ describe('Core API - Validate', () => {
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid modifier', () => {
|
||||
it.each([
|
||||
['name_invalid', 'name_invalid', defaultDocumentsOrder],
|
||||
['name_invalid (asc)', 'name_invalid:asc', defaultDocumentsOrder],
|
||||
['name_invalid (desc)', 'name_invalid:desc', defaultDocumentsOrder],
|
||||
])('Error with sort: %s', async (_s, sort, order) => {
|
||||
const res = await rq.get('/api/documents', { qs: { sort } });
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password', () => {
|
||||
@ -605,6 +628,17 @@ describe('Core API - Validate', () => {
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid modifier', () => {
|
||||
it.each([
|
||||
['name_invalid (asc)', [{ name_invalid: 'asc' }], defaultDocumentsOrder],
|
||||
['name_invalid (desc)', [{ name_private: 'desc' }], defaultDocumentsOrder],
|
||||
])('Error on sort: %s', async (_s, sort, order) => {
|
||||
const res = await rq.get('/api/documents', { qs: { sort } });
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password', () => {
|
||||
|
@ -86,6 +86,12 @@ module.exports = {
|
||||
relation: 'manyToMany',
|
||||
target: 'api::category.category',
|
||||
},
|
||||
categories_private: {
|
||||
private: true,
|
||||
type: 'relation',
|
||||
relation: 'manyToMany',
|
||||
target: 'api::category.category',
|
||||
},
|
||||
dz: {
|
||||
pluginOptions: {
|
||||
i18n: {
|
||||
|
@ -0,0 +1,254 @@
|
||||
import type { Core, Modules } from '@strapi/types';
|
||||
import { errors } from '@strapi/utils';
|
||||
import { createTestSetup, destroyTestSetup } from '../../../../utils/builder-helper';
|
||||
import resources from '../resources/index';
|
||||
import { ARTICLE_UID } from '../utils';
|
||||
|
||||
let strapi: Core.Strapi;
|
||||
|
||||
describe('Document Service Validations', () => {
|
||||
let testUtils;
|
||||
|
||||
beforeAll(async () => {
|
||||
testUtils = await createTestSetup(resources);
|
||||
strapi = testUtils.strapi;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestSetup(testUtils);
|
||||
});
|
||||
|
||||
const methods = [
|
||||
'findMany',
|
||||
'findFirst',
|
||||
'findOne',
|
||||
'publish',
|
||||
'delete',
|
||||
'create',
|
||||
'unpublish',
|
||||
'clone',
|
||||
'update',
|
||||
'discardDraft',
|
||||
'count',
|
||||
];
|
||||
|
||||
describe.each(methods)('%s method', (methodName) => {
|
||||
describe('sort', () => {
|
||||
it('should not throw on existing attribute name', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({ sort: 'title' });
|
||||
});
|
||||
|
||||
it('should not throw on private attribute', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({ sort: 'private' });
|
||||
});
|
||||
|
||||
it('should not throw on password attribute', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({ sort: 'password' });
|
||||
});
|
||||
|
||||
it('should not throw on existing nested (object) key', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
populate: { categories: { sort: { name: 'asc' } } },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw on existing nested (dot separated) key', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
sort: 'categories.name',
|
||||
populate: 'categories',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ValidationError on invalid key', async () => {
|
||||
await expect(
|
||||
strapi.documents(ARTICLE_UID)[methodName]({ sort: 'fakekey' })
|
||||
).rejects.toThrow(errors.ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filters', () => {
|
||||
it('should not throw on existing attribute equality', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
filters: {
|
||||
title: 'Hello World',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw on private attribute', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
filters: {
|
||||
private: 'Hello World',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw on password attribute', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
filters: {
|
||||
password: 'Hello World',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw on existing nested conditions', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
filters: {
|
||||
title: {
|
||||
$not: {
|
||||
$contains: 'Hello World',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ValidationError on invalid key', async () => {
|
||||
await expect(
|
||||
strapi.documents(ARTICLE_UID)[methodName]({
|
||||
filters: {
|
||||
fakekey: 'Hello World',
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(errors.ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fields', () => {
|
||||
it('should not throw on existing attribute equality', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
fields: ['title'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw on private attribute', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
fields: ['private'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw on password attribute', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
fields: ['password'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ValidationError on invalid key', async () => {
|
||||
await expect(
|
||||
strapi.documents(ARTICLE_UID)[methodName]({
|
||||
fields: ['title', 'fakekey'],
|
||||
})
|
||||
).rejects.toThrow(errors.ValidationError);
|
||||
});
|
||||
|
||||
it('should not throw on valid comma separated keys', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({ fields: 'title,password,private' });
|
||||
});
|
||||
|
||||
it('should throw on invalid comma separated keys', async () => {
|
||||
await expect(
|
||||
strapi.documents(ARTICLE_UID)[methodName]({ fields: 'title,invalid' })
|
||||
).rejects.toThrow(errors.ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('populate', () => {
|
||||
it('should not throw on populatable attribute', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
populate: ['categories'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw on private attribute', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
populate: ['categories_private'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw on wildcard *', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
populate: '*',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw on dz (boolean)', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
populate: {
|
||||
identifiersDz: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw on dz - comp (boolean)', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
populate: {
|
||||
identifiersDz: {
|
||||
on: {
|
||||
'article.compo-unique-all': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw on dz', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
populate: {
|
||||
identifiersDz: {
|
||||
on: {
|
||||
'article.compo-unique-all': {
|
||||
fields: ['ComponentTextShort'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw on nested wildcard populate', async () => {
|
||||
await strapi.documents(ARTICLE_UID)[methodName]({
|
||||
populate: {
|
||||
identifiersDz: {
|
||||
on: {
|
||||
'article.compo-unique-all': {
|
||||
populate: '*',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: functionality is not yet implemented
|
||||
it('should throw ValidationError on invalid dz component', async () => {
|
||||
await expect(
|
||||
strapi.documents(ARTICLE_UID)[methodName]({
|
||||
populate: {
|
||||
identifiersDz: {
|
||||
on: {
|
||||
invalidkey: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(errors.ValidationError);
|
||||
});
|
||||
|
||||
it('should throw ValidationError on non-populatable attribute', async () => {
|
||||
await expect(
|
||||
strapi.documents(ARTICLE_UID)[methodName]({
|
||||
populate: ['title'],
|
||||
})
|
||||
).rejects.toThrow(errors.ValidationError);
|
||||
});
|
||||
|
||||
it('should throw ValidationError on invalid key', async () => {
|
||||
await expect(
|
||||
strapi.documents(ARTICLE_UID)[methodName]({
|
||||
populate: ['categories', 'fakekey'],
|
||||
})
|
||||
).rejects.toThrow(errors.ValidationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user