Validate product and collection handles to be URL safe (#7310)
* fix: allow URL safe characters for product handle Fixes: CORE-2072
This commit is contained in:
1 parent
7c4f4d7388
commit
17486cda99
8 files changed
+146
-10
No files matched your search
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -58,3 +58,5 @@ export * from "./transaction"
|
|||||||
export * from "./trim-zeros"
|
export * from "./trim-zeros"
|
||||||
export * from "./upper-case-first"
|
export * from "./upper-case-first"
|
||||||
export * from "./wrap-handler"
|
export * from "./wrap-handler"
|
||||||
|
export * from "./to-handle"
|
||||||
|
export * from "./validate-handle"
|
||||||
@@ -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, "")
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const kebabCase = (string) =>
|
export const kebabCase = (string: string): string =>
|
||||||
string
|
string
|
||||||
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
||||||
.replace(/[\s_]+/g, "-")
|
.replace(/[\s_]+/g, "-")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -17,9 +17,9 @@ import {
|
|||||||
createPsqlIndexStatementHelper,
|
createPsqlIndexStatementHelper,
|
||||||
DALUtils,
|
DALUtils,
|
||||||
generateEntityId,
|
generateEntityId,
|
||||||
kebabCase,
|
|
||||||
ProductUtils,
|
ProductUtils,
|
||||||
Searchable,
|
Searchable,
|
||||||
|
toHandle,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
import ProductCategory from "./product-category"
|
import ProductCategory from "./product-category"
|
||||||
import ProductCollection from "./product-collection"
|
import ProductCollection from "./product-collection"
|
||||||
@@ -216,7 +216,7 @@ class Product {
|
|||||||
this.collection_id ??= this.collection?.id ?? null
|
this.collection_id ??= this.collection?.id ?? null
|
||||||
|
|
||||||
if (!this.handle && this.title) {
|
if (!this.handle && this.title) {
|
||||||
this.handle = kebabCase(this.title)
|
this.handle = toHandle(this.title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
ProductStatus,
|
ProductStatus,
|
||||||
promiseAll,
|
promiseAll,
|
||||||
removeUndefined,
|
removeUndefined,
|
||||||
|
isValidHandle,
|
||||||
|
toHandle,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
import {
|
import {
|
||||||
ProductCategoryEventData,
|
ProductCategoryEventData,
|
||||||
@@ -1165,9 +1167,14 @@ export default class ProductModuleService<
|
|||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<TProduct[]> {
|
): Promise<TProduct[]> {
|
||||||
const normalizedInput = await promiseAll(
|
const normalizedInput = await promiseAll(
|
||||||
data.map(
|
data.map(async (d) => {
|
||||||
async (d) => await this.normalizeCreateProductInput(d, sharedContext)
|
const normalized = await this.normalizeCreateProductInput(
|
||||||
)
|
d,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
this.validateProductPayload(normalized)
|
||||||
|
return normalized
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
const productData = await this.productService_.upsertWithReplace(
|
const productData = await this.productService_.upsertWithReplace(
|
||||||
@@ -1223,9 +1230,14 @@ export default class ProductModuleService<
|
|||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<TProduct[]> {
|
): Promise<TProduct[]> {
|
||||||
const normalizedInput = await promiseAll(
|
const normalizedInput = await promiseAll(
|
||||||
data.map(
|
data.map(async (d) => {
|
||||||
async (d) => await this.normalizeUpdateProductInput(d, sharedContext)
|
const normalized = await this.normalizeUpdateProductInput(
|
||||||
)
|
d,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
this.validateProductPayload(normalized)
|
||||||
|
return normalized
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
const productData = await this.productService_.upsertWithReplace(
|
const productData = await this.productService_.upsertWithReplace(
|
||||||
@@ -1306,6 +1318,21 @@ export default class ProductModuleService<
|
|||||||
return productData
|
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(
|
protected async normalizeCreateProductInput(
|
||||||
product: ProductTypes.CreateProductDTO,
|
product: ProductTypes.CreateProductDTO,
|
||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
@@ -1316,7 +1343,7 @@ export default class ProductModuleService<
|
|||||||
)) as ProductTypes.CreateProductDTO
|
)) as ProductTypes.CreateProductDTO
|
||||||
|
|
||||||
if (!productData.handle && productData.title) {
|
if (!productData.handle && productData.title) {
|
||||||
productData.handle = kebabCase(productData.title)
|
productData.handle = toHandle(productData.title)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!productData.status) {
|
if (!productData.status) {
|
||||||
|
|||||||
Reference in new issue
Block a user