diff --git a/packages/core/utils/src/common/__tests__/is-valid-handle.spec.ts b/packages/core/utils/src/common/__tests__/is-valid-handle.spec.ts new file mode 100644 index 0000000000..4d89e04d11 --- /dev/null +++ b/packages/core/utils/src/common/__tests__/is-valid-handle.spec.ts @@ -0,0 +1,36 @@ +import { isValidHandle } from "../validate-handle" + +describe("normalizeHandle", function () { + it("should generate URL friendly handles", function () { + const expectations = [ + { + input: "the-fan-boy's-club", + isValid: false, + }, + { + input: "@t-the-sky", + isValid: false, + }, + { + input: "nouvelles-annees", + isValid: true, + }, + { + input: "@t-the-sky", + isValid: false, + }, + { + input: "user.product", + isValid: false, + }, + { + input: 'sky"bar', + isValid: false, + }, + ] + + expectations.forEach((expectation) => { + expect(isValidHandle(expectation.input)).toEqual(expectation.isValid) + }) + }) +}) diff --git a/packages/core/utils/src/common/__tests__/to-handle.spec.ts b/packages/core/utils/src/common/__tests__/to-handle.spec.ts new file mode 100644 index 0000000000..ddc1869247 --- /dev/null +++ b/packages/core/utils/src/common/__tests__/to-handle.spec.ts @@ -0,0 +1,44 @@ +import { toHandle } from "../to-handle" + +describe("normalizeHandle", function () { + it("should generate URL friendly handles", function () { + const expectations = [ + { + input: "The fan boy's club", + output: "the-fan-boys-club", + }, + { + input: "nouvelles années", + output: "nouvelles-annees", + }, + { + input: "25% OFF", + output: "25-off", + }, + { + input: "25% de réduction", + output: "25-de-reduction", + }, + { + input: "-first-product", + output: "-first-product", + }, + { + input: "user.product", + output: "userproduct", + }, + { + input: "_first-product", + output: "-first-product", + }, + { + input: "_HELLO_WORLD", + output: "-hello-world", + }, + ] + + expectations.forEach((expectation) => { + expect(toHandle(expectation.input)).toEqual(expectation.output) + }) + }) +}) diff --git a/packages/core/utils/src/common/index.ts b/packages/core/utils/src/common/index.ts index 2b795e89d0..4fc71a54c1 100644 --- a/packages/core/utils/src/common/index.ts +++ b/packages/core/utils/src/common/index.ts @@ -58,3 +58,5 @@ export * from "./transaction" export * from "./trim-zeros" export * from "./upper-case-first" export * from "./wrap-handler" +export * from "./to-handle" +export * from "./validate-handle" diff --git a/packages/core/utils/src/common/to-handle.ts b/packages/core/utils/src/common/to-handle.ts new file mode 100644 index 0000000000..56800cd2c4 --- /dev/null +++ b/packages/core/utils/src/common/to-handle.ts @@ -0,0 +1,18 @@ +import { kebabCase } from "./to-kebab-case" + +/** + * Helper method to create a to be URL friendly "handle" from + * a string value. + * + * - Works by converting the value to lowercase + * - Splits and remove accents from characters + * - Removes all unallowed characters like a '"%$ and so on. + */ +export const toHandle = (value: string): string => { + return kebabCase( + value + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + ).replace(/[^a-z0-9A-Z-_]/g, "") +} diff --git a/packages/core/utils/src/common/to-kebab-case.ts b/packages/core/utils/src/common/to-kebab-case.ts index 6b42ee3985..5a975cd035 100644 --- a/packages/core/utils/src/common/to-kebab-case.ts +++ b/packages/core/utils/src/common/to-kebab-case.ts @@ -1,4 +1,4 @@ -export const kebabCase = (string) => +export const kebabCase = (string: string): string => string .replace(/([a-z])([A-Z])/g, "$1-$2") .replace(/[\s_]+/g, "-") diff --git a/packages/core/utils/src/common/validate-handle.ts b/packages/core/utils/src/common/validate-handle.ts new file mode 100644 index 0000000000..01edd607a3 --- /dev/null +++ b/packages/core/utils/src/common/validate-handle.ts @@ -0,0 +1,9 @@ +import { kebabCase } from "./to-kebab-case" + +/** + * Helper method to validate entity "handle" to be URL + * friendly. + */ +export const isValidHandle = (value: string): boolean => { + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) +} diff --git a/packages/modules/product/src/models/product.ts b/packages/modules/product/src/models/product.ts index 46e72ecf28..a6875141ef 100644 --- a/packages/modules/product/src/models/product.ts +++ b/packages/modules/product/src/models/product.ts @@ -17,9 +17,9 @@ import { createPsqlIndexStatementHelper, DALUtils, generateEntityId, - kebabCase, ProductUtils, Searchable, + toHandle, } from "@medusajs/utils" import ProductCategory from "./product-category" import ProductCollection from "./product-collection" @@ -216,7 +216,7 @@ class Product { this.collection_id ??= this.collection?.id ?? null if (!this.handle && this.title) { - this.handle = kebabCase(this.title) + this.handle = toHandle(this.title) } } } diff --git a/packages/modules/product/src/services/product-module-service.ts b/packages/modules/product/src/services/product-module-service.ts index f7723e938d..5ce5ba0be4 100644 --- a/packages/modules/product/src/services/product-module-service.ts +++ b/packages/modules/product/src/services/product-module-service.ts @@ -32,6 +32,8 @@ import { ProductStatus, promiseAll, removeUndefined, + isValidHandle, + toHandle, } from "@medusajs/utils" import { ProductCategoryEventData, @@ -1165,9 +1167,14 @@ export default class ProductModuleService< @MedusaContext() sharedContext: Context = {} ): Promise { const normalizedInput = await promiseAll( - data.map( - async (d) => await this.normalizeCreateProductInput(d, sharedContext) - ) + data.map(async (d) => { + const normalized = await this.normalizeCreateProductInput( + d, + sharedContext + ) + this.validateProductPayload(normalized) + return normalized + }) ) const productData = await this.productService_.upsertWithReplace( @@ -1223,9 +1230,14 @@ export default class ProductModuleService< @MedusaContext() sharedContext: Context = {} ): Promise { const normalizedInput = await promiseAll( - data.map( - async (d) => await this.normalizeUpdateProductInput(d, sharedContext) - ) + data.map(async (d) => { + const normalized = await this.normalizeUpdateProductInput( + d, + sharedContext + ) + this.validateProductPayload(normalized) + return normalized + }) ) const productData = await this.productService_.upsertWithReplace( @@ -1306,6 +1318,21 @@ export default class ProductModuleService< return productData } + /** + * Validates the manually provided handle value of the product + * to be URL-safe + */ + protected validateProductPayload( + productData: UpdateProductInput | ProductTypes.CreateProductDTO + ) { + if (productData.handle && !isValidHandle(productData.handle)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Invalid product handle. It must contain URL safe characters" + ) + } + } + protected async normalizeCreateProductInput( product: ProductTypes.CreateProductDTO, @MedusaContext() sharedContext: Context = {} @@ -1316,7 +1343,7 @@ export default class ProductModuleService< )) as ProductTypes.CreateProductDTO if (!productData.handle && productData.title) { - productData.handle = kebabCase(productData.title) + productData.handle = toHandle(productData.title) } if (!productData.status) {