Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
Comment thread
boazpoolman marked this conversation as resolved.
]
}
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