diff --git a/.editorconfig b/.editorconfig index 8b1709b6..db1db0f2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,6 +7,7 @@ end_of_line = LF charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true +quote_type = single [*.md] trim_trailing_whitespace = false \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..fda5ad57 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "editorconfig.editorconfig" + ] +} diff --git a/packages/core/server/controllers/url-pattern.ts b/packages/core/server/controllers/url-pattern.ts index 2e41866e..63bbdb23 100644 --- a/packages/core/server/controllers/url-pattern.ts +++ b/packages/core/server/controllers/url-pattern.ts @@ -48,7 +48,7 @@ export default factories.createCoreController(contentTypeSlug, ({ strapi }) => ( 'uid', 'documentId', ]); - const validated = urlPatternService.validatePattern(pattern, fields); + const validated = urlPatternService.validatePattern(pattern, fields, contentType); ctx.body = validated; }, diff --git a/packages/core/server/services/__tests__/url-pattern.test.ts b/packages/core/server/services/__tests__/url-pattern.test.ts new file mode 100644 index 00000000..7f30c2fc --- /dev/null +++ b/packages/core/server/services/__tests__/url-pattern.test.ts @@ -0,0 +1,204 @@ +import urlPatternService from '../url-pattern'; + +// Mock getPluginService to return the service itself +jest.mock('../../util/getPluginService', () => ({ + getPluginService: () => urlPatternService, +})); + +jest.mock('@strapi/strapi', () => ({ + factories: { + createCoreService: (uid, cfg) => { + if (typeof cfg === 'function') return cfg(); + return cfg; + }, + }, +})); + +// Mock Strapi global +global.strapi = { + config: { + get: jest.fn((key) => { + if (key === 'plugin::webtools') return { slugify: (str) => str.toLowerCase().replace(/\s+/g, '-') }; + if (key === 'plugin::webtools.default_pattern') return '/[id]'; + return null; + }), + }, + contentTypes: { + 'api::article.article': { + attributes: { + title: { type: 'string' }, + categories: { + type: 'relation', + relation: 'manyToMany', + target: 'api::category.category', + }, + author: { + type: 'relation', + relation: 'oneToOne', + target: 'api::author.author', + } + }, + info: { pluralName: 'articles' }, + }, + 'api::category.category': { + attributes: { + slug: { type: 'string' }, + name: { type: 'string' }, + }, + }, + 'api::author.author': { + attributes: { + name: { type: 'string' }, + } + } + }, + log: { + error: jest.fn(), + }, +} as any; + + +describe('URL Pattern Service', () => { + const service = urlPatternService as any; + + describe('getAllowedFields', () => { + it('should return allowed fields including ToMany relations', () => { + const contentType = strapi.contentTypes['api::article.article']; + const allowedFields = ['string', 'uid']; + const fields = service.getAllowedFields(contentType, allowedFields); + + expect(fields).toContain('title'); + expect(fields).toContain('author.name'); + // This is the new feature we want to support + expect(fields).toContain('categories.slug'); + }); + + it('should return allowed fields for underscored relation name', () => { + const contentType = { + attributes: { + private_categories: { + type: 'relation', + relation: 'manyToMany', + target: 'api::category.category', + }, + }, + } as any; + + // Mock strapi.contentTypes for the target + strapi.contentTypes['api::category.category'] = { + attributes: { + slug: { type: 'uid' }, + }, + } as any; + + const allowedFields = ['uid']; + const fields = service.getAllowedFields(contentType, allowedFields); + + expect(fields).toContain('private_categories.slug'); + }); + }); + + describe('resolvePattern', () => { + it('should resolve pattern with ToMany relation array syntax', () => { + const uid = 'api::article.article'; + const entity = { + title: 'My Article', + categories: [ + { slug: 'tech', name: 'Technology' }, + { slug: 'news', name: 'News' }, + ], + }; + const pattern = '/articles/[categories[0].slug]/[title]'; + + const resolved = service.resolvePattern(uid, entity, pattern); + + expect(resolved).toBe('/articles/tech/my-article'); + }); + + it('should resolve pattern with dashed relation name', () => { + const uid = 'api::article.article'; + const entity = { + 'private-categories': [ + { slug: 'tech' }, + ], + }; + const pattern = '/articles/[private-categories[0].slug]'; + + const resolved = service.resolvePattern(uid, entity, pattern); + + expect(resolved).toBe('/articles/tech'); + }); + + it('should handle missing array index gracefully', () => { + const uid = 'api::article.article'; + const entity = { + title: 'My Article', + categories: [], + }; + const pattern = '/articles/[categories[0].slug]/[title]'; + + const resolved = service.resolvePattern(uid, entity, pattern); + + // Should probably result in empty string for that part or handle it? + // Current implementation replaces with empty string if missing. + expect(resolved).toBe('/articles/my-article'); + }); + }); + + describe('validatePattern', () => { + it('should invalidate pattern with ToMany relation missing array index', () => { + const pattern = '/test/[private_categories.slug]/1'; + const allowedFields = ['private_categories.slug']; + const contentType = { + attributes: { + private_categories: { + type: 'relation', + relation: 'manyToMany', + target: 'api::category.category', + }, + }, + } as any; + + const result = service.validatePattern(pattern, allowedFields, contentType); + + expect(result.valid).toBe(false); + expect(result.message).toContain('must include an array index'); + }); + + it('should validate pattern with underscored relation name', () => { + const pattern = '/test/[private_categories[0].slug]/1'; + const allowedFields = ['private_categories.slug']; + + const result = service.validatePattern(pattern, allowedFields); + + expect(result.valid).toBe(true); + }); + + it('should validate pattern with dashed relation name', () => { + const pattern = '/test/[private-categories[0].slug]/1'; + const allowedFields = ['private-categories.slug']; + + const result = service.validatePattern(pattern, allowedFields); + + expect(result.valid).toBe(true); + }); + it('should invalidate pattern with forbidden fields', () => { + const pattern = '/articles/[forbidden]/[title]'; + const allowedFields = ['title']; + + const result = service.validatePattern(pattern, allowedFields); + + expect(result.valid).toBe(false); + }); + }); + + describe('getRelationsFromPattern', () => { + it('should return relation name without array index', () => { + const pattern = '/articles/[categories[0].slug]/[title]'; + const relations = service.getRelationsFromPattern(pattern); + + expect(relations).toContain('categories'); + expect(relations).not.toContain('categories[0]'); + }); + }); +}); diff --git a/packages/core/server/services/url-pattern.ts b/packages/core/server/services/url-pattern.ts index 24d08f16..b7efb547 100644 --- a/packages/core/server/services/url-pattern.ts +++ b/packages/core/server/services/url-pattern.ts @@ -48,7 +48,6 @@ const customServices = () => ({ fields.push(fieldName); } else if ( field.type === 'relation' - && field.relation.endsWith('ToOne') // TODO: implement `ToMany` relations. && fieldName !== 'localizations' && fieldName !== 'createdBy' && fieldName !== 'updatedBy' @@ -105,13 +104,13 @@ const customServices = () => ({ * @returns {string[]} The extracted fields. */ getFieldsFromPattern: (pattern: string): string[] => { - const fields = pattern.match(/[[\w\d.]+]/g); // Get all substrings between [] as array. + const fields = pattern.match(/\[[\w\d.\-[\]]+\]/g); // Get all substrings between [] as array. if (!fields) { return []; } - const newFields = fields.map((field) => (/(?<=\[)(.*?)(?=\])/).exec(field)?.[0] ?? ''); // Strip [] from string. + const newFields = fields.map((field) => field.slice(1, -1)); // Strip [] from string. return newFields; }, @@ -130,7 +129,10 @@ const customServices = () => ({ fields = fields.filter((field) => field); // For fields containing dots, extract the first part (relation) - const relations = fields.filter((field) => field.includes('.')).map((field) => field.split('.')[0]); + const relations = fields + .filter((field) => field.includes('.')) + .map((field) => field.split('.')[0]) + .map((relation) => relation.replace(/\[\d+\]/g, '')); // Strip array index return relations; }, @@ -146,7 +148,7 @@ const customServices = () => ({ */ resolvePattern: ( uid: UID.ContentType, - entity: { [key: string]: any }, + entity: Record, urlPattern?: string, ): string => { const resolve = (pattern: string) => { @@ -171,10 +173,41 @@ const customServices = () => ({ } else if (!relationalField) { const fieldValue = slugify(String(entity[field])); resolvedPattern = resolvedPattern.replace(`[${field}]`, fieldValue || ''); - } else if (Array.isArray(entity[relationalField[0]])) { - strapi.log.error('Something went wrong whilst resolving the pattern.'); - } else if (typeof entity[relationalField[0]] === 'object') { - resolvedPattern = resolvedPattern.replace(`[${field}]`, entity[relationalField[0]] && String((entity[relationalField[0]] as any[])[relationalField[1]]) ? slugify(String((entity[relationalField[0]] as any[])[relationalField[1]])) : ''); + } else { + let relationName = relationalField[0]; + let relationIndex: number | null = null; + + const arrayMatch = relationName.match(/^([\w-]+)\[(\d+)\]$/); + if (arrayMatch) { + const [, name, index] = arrayMatch; + relationName = name; + relationIndex = parseInt(index, 10); + } + + const relationEntity = entity[relationName]; + + if (Array.isArray(relationEntity) && relationIndex !== null) { + const subEntity = relationEntity[relationIndex] as + | Record + | undefined; + const value = subEntity?.[relationalField[1]]; + resolvedPattern = resolvedPattern.replace( + `[${field}]`, + value ? slugify(String(value)) : '', + ); + } else if ( + typeof relationEntity === 'object' + && relationEntity !== null + && !Array.isArray(relationEntity) + ) { + const value = (relationEntity as Record)?.[relationalField[1]]; + resolvedPattern = resolvedPattern.replace( + `[${field}]`, + value ? slugify(String(value)) : '', + ); + } else { + strapi.log.error('Something went wrong whilst resolving the pattern.'); + } } }); @@ -194,54 +227,62 @@ const customServices = () => ({ /** * Validate if a pattern is correctly structured. * - * @param {string[]} pattern - The pattern to validate. - * @param {string[]} allowedFieldNames - The allowed field names in the pattern. + * @param {string} pattern - The pattern to validate. + * @param {string[]} allowedFieldNames - The allowed fields. + * @param {Schema.ContentType} contentType - The content type. * @returns {object} The validation result. - * @returns {boolean} object.valid - Validation boolean. - * @returns {string} object.message - Validation message. */ - validatePattern: (pattern: string, allowedFieldNames: string[]) => { - if (!pattern.length) { + validatePattern: ( + pattern: string, + allowedFieldNames: string[], + contentType?: Schema.ContentType, + ): { valid: boolean, message: string } => { + if (!pattern) { return { valid: false, message: 'Pattern cannot be empty', }; } - const preCharCount = pattern.split('[').length - 1; - const postCharCount = pattern.split(']').length - 1; - - if (preCharCount < 1 || postCharCount < 1) { - return { - valid: false, - message: 'Pattern should contain at least one field', - }; - } - - if (preCharCount !== postCharCount) { - return { - valid: false, - message: 'Fields in the pattern are not escaped correctly', - }; - } - - let fieldsAreAllowed = true; - - // Pass the original `pattern` array to getFieldsFromPattern - getPluginService('url-pattern').getFieldsFromPattern(pattern).forEach((field) => { - if (!allowedFieldNames.includes(field)) fieldsAreAllowed = false; + const fields = getPluginService('url-pattern').getFieldsFromPattern(pattern); + let valid = true; + let message = ''; + + fields.forEach((field) => { + // Check if the field is allowed. + // We strip the array index from the field name to check if it is allowed. + // e.g. private_categories[0].slug -> private_categories.slug + const fieldName = field.replace(/\[\d+\]/g, ''); + if (!allowedFieldNames.includes(fieldName)) { + valid = false; + message = `Pattern contains forbidden fields: ${field}`; + } + + // Check if the field is a ToMany relation and has an array index. + if (contentType && field.includes('.')) { + const [relationName] = field.split('.'); + // Strip array index to get the attribute name + const attributeName = relationName.replace(/\[\d+\]/g, ''); + const attribute = contentType.attributes[ + attributeName + ] as Schema.Attribute.Relation | undefined; + + if ( + attribute + && attribute.type === 'relation' + && typeof attribute.relation === 'string' + && !attribute.relation.endsWith('ToOne') + && !relationName.includes('[') + ) { + valid = false; + message = `The relation ${attributeName} is a ToMany relation and must include an array index (e.g. ${attributeName}[0]).`; + } + } }); - if (!fieldsAreAllowed) { - return { - valid: false, - message: 'Pattern contains forbidden fields', - }; - } - return { - valid: true, - message: 'Valid pattern', + valid, + message, }; }, }); diff --git a/playground/src/api/private-category/content-types/private-category/schema.json b/playground/src/api/private-category/content-types/private-category/schema.json index 1706d632..709f7e27 100644 --- a/playground/src/api/private-category/content-types/private-category/schema.json +++ b/playground/src/api/private-category/content-types/private-category/schema.json @@ -16,14 +16,24 @@ } }, "attributes": { + "url_alias": { + "type": "relation", + "relation": "oneToMany", + "target": "plugin::webtools.url-alias", + "configurable": false + }, "title": { "type": "string" }, - "test": { + "tests": { "type": "relation", - "relation": "oneToOne", + "relation": "manyToMany", "target": "api::test.test", - "mappedBy": "private_category" + "inversedBy": "private_categories" + }, + "slug": { + "type": "uid", + "targetField": "title" } } } diff --git a/playground/src/api/test/content-types/test/schema.json b/playground/src/api/test/content-types/test/schema.json index 437f57c6..15c6c806 100644 --- a/playground/src/api/test/content-types/test/schema.json +++ b/playground/src/api/test/content-types/test/schema.json @@ -8,8 +8,7 @@ "description": "" }, "options": { - "draftAndPublish": true, - "populateCreatorFields": true + "draftAndPublish": true }, "pluginOptions": { "webtools": { @@ -34,21 +33,27 @@ "target": "api::category.category", "mappedBy": "test" }, - "private_category": { + "private_categories": { "type": "relation", - "relation": "oneToOne", + "relation": "manyToMany", "target": "api::private-category.private-category", - "inversedBy": "test" + "mappedBy": "tests" }, "header": { "type": "component", - "repeatable": true, "pluginOptions": { "i18n": { "localized": true } }, - "component": "core.header" + "component": "core.header", + "repeatable": true + }, + "url_alias": { + "type": "relation", + "relation": "oneToMany", + "target": "plugin::webtools.url-alias", + "configurable": false } } } diff --git a/playground/types/generated/contentTypes.d.ts b/playground/types/generated/contentTypes.d.ts index e648b598..11fe5d69 100644 --- a/playground/types/generated/contentTypes.d.ts +++ b/playground/types/generated/contentTypes.d.ts @@ -543,7 +543,8 @@ export interface ApiPrivateCategoryPrivateCategory sitemap_exclude: Schema.Attribute.Boolean & Schema.Attribute.Private & Schema.Attribute.DefaultTo; - test: Schema.Attribute.Relation<'oneToOne', 'api::test.test'>; + slug: Schema.Attribute.UID<'title'>; + tests: Schema.Attribute.Relation<'manyToMany', 'api::test.test'>; title: Schema.Attribute.String; updatedAt: Schema.Attribute.DateTime; updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & @@ -566,7 +567,6 @@ export interface ApiTestTest extends Struct.CollectionTypeSchema { }; options: { draftAndPublish: true; - populateCreatorFields: true; }; pluginOptions: { i18n: { @@ -579,7 +579,8 @@ export interface ApiTestTest extends Struct.CollectionTypeSchema { attributes: { category: Schema.Attribute.Relation<'oneToOne', 'api::category.category'>; createdAt: Schema.Attribute.DateTime; - createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'>; + createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; header: Schema.Attribute.Component<'core.header', true> & Schema.Attribute.SetPluginOptions<{ i18n: { @@ -588,8 +589,8 @@ export interface ApiTestTest extends Struct.CollectionTypeSchema { }>; locale: Schema.Attribute.String; localizations: Schema.Attribute.Relation<'oneToMany', 'api::test.test'>; - private_category: Schema.Attribute.Relation< - 'oneToOne', + private_categories: Schema.Attribute.Relation< + 'manyToMany', 'api::private-category.private-category' >; publishedAt: Schema.Attribute.DateTime; @@ -603,7 +604,8 @@ export interface ApiTestTest extends Struct.CollectionTypeSchema { }; }>; updatedAt: Schema.Attribute.DateTime; - updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'>; + updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; url_alias: Schema.Attribute.Relation< 'oneToMany', 'plugin::webtools.url-alias'