Skip to content
Open
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"recommendations": [
"editorconfig.editorconfig"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this recommendation file to remind the developer to install EditorConfig and set up tab-size indentation.

]
}
185 changes: 185 additions & 0 deletions packages/core/server/services/__tests__/url-pattern.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
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 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]');
});
});
});
39 changes: 30 additions & 9 deletions packages/core/server/services/url-pattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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;
},
Expand All @@ -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;
},
Expand Down Expand Up @@ -171,10 +173,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.');
}
}
});

Expand Down Expand Up @@ -229,7 +249,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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
19 changes: 12 additions & 7 deletions playground/src/api/test/content-types/test/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
"description": ""
},
"options": {
"draftAndPublish": true,
"populateCreatorFields": true
"draftAndPublish": true
},
"pluginOptions": {
"webtools": {
Expand All @@ -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
}
}
}
14 changes: 8 additions & 6 deletions playground/types/generated/contentTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,8 @@ export interface ApiPrivateCategoryPrivateCategory
sitemap_exclude: Schema.Attribute.Boolean &
Schema.Attribute.Private &
Schema.Attribute.DefaultTo<false>;
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'> &
Expand All @@ -566,7 +567,6 @@ export interface ApiTestTest extends Struct.CollectionTypeSchema {
};
options: {
draftAndPublish: true;
populateCreatorFields: true;
};
pluginOptions: {
i18n: {
Expand All @@ -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: {
Expand All @@ -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;
Expand All @@ -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'
Expand Down