From 661a844a3deb0a47a329d521552b04f1ab27757a Mon Sep 17 00:00:00 2001 From: Candido Sales Gomes Date: Sun, 23 Nov 2025 11:06:08 -0500 Subject: [PATCH 1/7] feat: Implement support for ToMany relation array syntax in URL patterns. --- .editorconfig | 1 + .../services/__tests__/url-pattern.test.ts | 128 ++++++++++++++++++ packages/core/server/services/url-pattern.ts | 35 +++-- 3 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 packages/core/server/services/__tests__/url-pattern.test.ts 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/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..469908fe --- /dev/null +++ b/packages/core/server/services/__tests__/url-pattern.test.ts @@ -0,0 +1,128 @@ +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'); + }); + }); + + 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 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 validate pattern with ToMany relation array syntax', () => { + const pattern = '/articles/[categories[0].slug]/[title]'; + const allowedFields = ['title', 'categories.slug', 'author.name']; + + 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); + }); + }); + }); +}); diff --git a/packages/core/server/services/url-pattern.ts b/packages/core/server/services/url-pattern.ts index 24d08f16..2130028a 100644 --- a/packages/core/server/services/url-pattern.ts +++ b/packages/core/server/services/url-pattern.ts @@ -48,7 +48,7 @@ const customServices = () => ({ fields.push(fieldName); } else if ( field.type === 'relation' - && field.relation.endsWith('ToOne') // TODO: implement `ToMany` relations. + // && field.relation.endsWith('ToOne') // TODO: implement `ToMany` relations. && fieldName !== 'localizations' && fieldName !== 'createdBy' && fieldName !== 'updatedBy' @@ -105,13 +105,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; }, @@ -171,10 +171,28 @@ 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) { + relationName = arrayMatch[1]; + relationIndex = parseInt(arrayMatch[2], 10); + } + + const relationEntity = entity[relationName]; + + if (Array.isArray(relationEntity) && relationIndex !== null) { + const subEntity = relationEntity[relationIndex]; + const value = subEntity?.[relationalField[1]]; + resolvedPattern = resolvedPattern.replace(`[${field}]`, value ? slugify(String(value)) : ''); + } else if (typeof relationEntity === 'object' && !Array.isArray(relationEntity)) { + const value = relationEntity?.[relationalField[1]]; + resolvedPattern = resolvedPattern.replace(`[${field}]`, value ? slugify(String(value)) : ''); + } else { + strapi.log.error('Something went wrong whilst resolving the pattern.'); + } } }); @@ -229,7 +247,8 @@ const customServices = () => ({ // Pass the original `pattern` array to getFieldsFromPattern getPluginService('url-pattern').getFieldsFromPattern(pattern).forEach((field) => { - if (!allowedFieldNames.includes(field)) fieldsAreAllowed = false; + const fieldName = field.replace(/\[\d+\]/g, ''); + if (!allowedFieldNames.includes(fieldName)) fieldsAreAllowed = false; }); if (!fieldsAreAllowed) { From aeabd36df92aaf1e6095d22871b0f086fa7953cf Mon Sep 17 00:00:00 2001 From: Candido Sales Gomes Date: Sun, 23 Nov 2025 11:19:36 -0500 Subject: [PATCH 2/7] feat: enhance URL pattern parsing to support hyphens and array syntax, and update playground content types with new relations and fields. --- .vscode/extensions.json | 5 + .../services/__tests__/url-pattern.test.ts | 263 +++++++++++------- packages/core/server/services/url-pattern.ts | 4 +- .../private-category/schema.json | 16 +- .../api/test/content-types/test/schema.json | 19 +- playground/types/generated/contentTypes.d.ts | 14 +- 6 files changed, 199 insertions(+), 122 deletions(-) create mode 100644 .vscode/extensions.json 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/services/__tests__/url-pattern.test.ts b/packages/core/server/services/__tests__/url-pattern.test.ts index 469908fe..66e935f3 100644 --- a/packages/core/server/services/__tests__/url-pattern.test.ts +++ b/packages/core/server/services/__tests__/url-pattern.test.ts @@ -2,127 +2,182 @@ import urlPatternService from '../url-pattern'; // Mock getPluginService to return the service itself jest.mock('../../util/getPluginService', () => ({ - getPluginService: () => urlPatternService, + getPluginService: () => urlPatternService, })); jest.mock('@strapi/strapi', () => ({ - factories: { - createCoreService: (uid, cfg) => { - if (typeof cfg === 'function') return cfg(); - return cfg; - }, + 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' }, + 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', }, - 'api::category.category': { - attributes: { - slug: { type: 'string' }, - name: { type: 'string' }, - }, - }, - 'api::author.author': { - attributes: { - name: { type: 'string' }, - } + author: { + type: 'relation', + relation: 'oneToOne', + target: 'api::author.author', } + }, + info: { pluralName: 'articles' }, }, - log: { - error: jest.fn(), + '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'); - }); + 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'); }); - 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 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 validate pattern with ToMany relation array syntax', () => { - const pattern = '/articles/[categories[0].slug]/[title]'; - const allowedFields = ['title', 'categories.slug', 'author.name']; - - 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); - }); - }); + 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 validate pattern with ToMany relation array syntax', () => { + const pattern = '/articles/[categories[0].slug]/[title]'; + const allowedFields = ['title', 'categories.slug', 'author.name']; + + const result = service.validatePattern(pattern, allowedFields); + + expect(result.valid).toBe(true); + }); + + 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); + }); }); + }); }); diff --git a/packages/core/server/services/url-pattern.ts b/packages/core/server/services/url-pattern.ts index 2130028a..27864c43 100644 --- a/packages/core/server/services/url-pattern.ts +++ b/packages/core/server/services/url-pattern.ts @@ -105,7 +105,7 @@ 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 []; @@ -175,7 +175,7 @@ const customServices = () => ({ let relationName = relationalField[0]; let relationIndex: number | null = null; - const arrayMatch = relationName.match(/^(\w+)\[(\d+)\]$/); + const arrayMatch = relationName.match(/^([\w-]+)\[(\d+)\]$/); if (arrayMatch) { relationName = arrayMatch[1]; relationIndex = parseInt(arrayMatch[2], 10); 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' From 10f90f016251814de912fe5e97dca6a36117a610 Mon Sep 17 00:00:00 2001 From: Candido Sales Gomes Date: Sun, 23 Nov 2025 11:33:02 -0500 Subject: [PATCH 3/7] feat: improve URL pattern relation extraction by stripping array indices --- .../services/__tests__/url-pattern.test.ts | 52 ++++++++++--------- packages/core/server/services/url-pattern.ts | 5 +- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/packages/core/server/services/__tests__/url-pattern.test.ts b/packages/core/server/services/__tests__/url-pattern.test.ts index 66e935f3..a426b375 100644 --- a/packages/core/server/services/__tests__/url-pattern.test.ts +++ b/packages/core/server/services/__tests__/url-pattern.test.ts @@ -143,41 +143,43 @@ describe('URL Pattern Service', () => { // Current implementation replaces with empty string if missing. expect(resolved).toBe('/articles/my-article'); }); - describe('validatePattern', () => { - it('should validate pattern with ToMany relation array syntax', () => { - const pattern = '/articles/[categories[0].slug]/[title]'; - const allowedFields = ['title', 'categories.slug', 'author.name']; + }); - const result = service.validatePattern(pattern, allowedFields); + describe('validatePattern', () => { + it('should validate pattern with underscored relation name', () => { + const pattern = '/test/[private_categories[0].slug]/1'; + const allowedFields = ['private_categories.slug']; - expect(result.valid).toBe(true); - }); + const result = service.validatePattern(pattern, allowedFields); - it('should validate pattern with underscored relation name', () => { - const pattern = '/test/[private_categories[0].slug]/1'; - const allowedFields = ['private_categories.slug']; + expect(result.valid).toBe(true); + }); - const result = service.validatePattern(pattern, allowedFields); + it('should validate pattern with dashed relation name', () => { + const pattern = '/test/[private-categories[0].slug]/1'; + const allowedFields = ['private-categories.slug']; - expect(result.valid).toBe(true); - }); + const result = service.validatePattern(pattern, allowedFields); - it('should validate pattern with dashed relation name', () => { - const pattern = '/test/[private-categories[0].slug]/1'; - const allowedFields = ['private-categories.slug']; + 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); + 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']; + expect(result.valid).toBe(false); + }); + }); - const result = service.validatePattern(pattern, allowedFields); + describe('getRelationsFromPattern', () => { + it('should return relation name without array index', () => { + const pattern = '/articles/[categories[0].slug]/[title]'; + const relations = service.getRelationsFromPattern(pattern); - expect(result.valid).toBe(false); - }); + 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 27864c43..e6054c6e 100644 --- a/packages/core/server/services/url-pattern.ts +++ b/packages/core/server/services/url-pattern.ts @@ -130,7 +130,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; }, From 2a6bbf687a504fcf510cbf9f389b146810634372 Mon Sep 17 00:00:00 2001 From: Candido Sales Gomes Date: Sun, 23 Nov 2025 11:36:57 -0500 Subject: [PATCH 4/7] feat: enable `ToMany` relations by removing commented restriction --- packages/core/server/services/url-pattern.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/server/services/url-pattern.ts b/packages/core/server/services/url-pattern.ts index e6054c6e..e860e7b6 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' From 888d218dc0704be353a715b3e54015b16018aafd Mon Sep 17 00:00:00 2001 From: Candido Sales Gomes Date: Sun, 23 Nov 2025 12:11:24 -0500 Subject: [PATCH 5/7] feat: Add validation for array index in ToMany relations within URL patterns. --- .../core/server/controllers/url-pattern.ts | 2 +- .../services/__tests__/url-pattern.test.ts | 19 +++++ packages/core/server/services/url-pattern.ts | 72 +++++++++---------- 3 files changed, 56 insertions(+), 37 deletions(-) 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 index a426b375..7f30c2fc 100644 --- a/packages/core/server/services/__tests__/url-pattern.test.ts +++ b/packages/core/server/services/__tests__/url-pattern.test.ts @@ -146,6 +146,25 @@ describe('URL Pattern Service', () => { }); 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']; diff --git a/packages/core/server/services/url-pattern.ts b/packages/core/server/services/url-pattern.ts index e860e7b6..9a3d6620 100644 --- a/packages/core/server/services/url-pattern.ts +++ b/packages/core/server/services/url-pattern.ts @@ -214,55 +214,55 @@ 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; + const fields = getPluginService('url-pattern').getFieldsFromPattern(pattern); + let valid = true; + let message = ''; - 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) => { + 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)) fieldsAreAllowed = false; + 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]; + + if ( + attribute + && attribute.type === 'relation' + && !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, }; }, }); From a130e2b8f7bd01c477ee1670258462a23adeb8a4 Mon Sep 17 00:00:00 2001 From: Candido Sales Gomes Date: Mon, 24 Nov 2025 18:50:52 -0500 Subject: [PATCH 6/7] fix: lint --- packages/core/server/services/url-pattern.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/core/server/services/url-pattern.ts b/packages/core/server/services/url-pattern.ts index 9a3d6620..1a936b5a 100644 --- a/packages/core/server/services/url-pattern.ts +++ b/packages/core/server/services/url-pattern.ts @@ -104,7 +104,7 @@ 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 []; @@ -179,8 +179,9 @@ const customServices = () => ({ const arrayMatch = relationName.match(/^([\w-]+)\[(\d+)\]$/); if (arrayMatch) { - relationName = arrayMatch[1]; - relationIndex = parseInt(arrayMatch[2], 10); + const [, name, index] = arrayMatch; + relationName = name; + relationIndex = parseInt(index, 10); } const relationEntity = entity[relationName]; @@ -219,7 +220,11 @@ const customServices = () => ({ * @param {Schema.ContentType} contentType - The content type. * @returns {object} The validation result. */ - validatePattern: (pattern: string, allowedFieldNames: string[], contentType?: Schema.ContentType): { valid: boolean, message: string } => { + validatePattern: ( + pattern: string, + allowedFieldNames: string[], + contentType?: Schema.ContentType, + ): { valid: boolean, message: string } => { if (!pattern) { return { valid: false, From c31162cec419f2052ede304e7cd9d21b62da2b49 Mon Sep 17 00:00:00 2001 From: Candido Sales Gomes Date: Fri, 28 Nov 2025 13:05:05 -0500 Subject: [PATCH 7/7] feat: refine type definitions for entity handling in URL pattern resolution --- packages/core/server/services/url-pattern.ts | 29 +++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/core/server/services/url-pattern.ts b/packages/core/server/services/url-pattern.ts index 1a936b5a..b7efb547 100644 --- a/packages/core/server/services/url-pattern.ts +++ b/packages/core/server/services/url-pattern.ts @@ -148,7 +148,7 @@ const customServices = () => ({ */ resolvePattern: ( uid: UID.ContentType, - entity: { [key: string]: any }, + entity: Record, urlPattern?: string, ): string => { const resolve = (pattern: string) => { @@ -187,12 +187,24 @@ const customServices = () => ({ const relationEntity = entity[relationName]; if (Array.isArray(relationEntity) && relationIndex !== null) { - const subEntity = relationEntity[relationIndex]; + 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' && !Array.isArray(relationEntity)) { - const value = relationEntity?.[relationalField[1]]; - resolvedPattern = resolvedPattern.replace(`[${field}]`, value ? slugify(String(value)) : ''); + 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.'); } @@ -251,11 +263,14 @@ const customServices = () => ({ const [relationName] = field.split('.'); // Strip array index to get the attribute name const attributeName = relationName.replace(/\[\d+\]/g, ''); - const attribute = contentType.attributes[attributeName]; + 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('[') ) {