fix(medusa): fields params usage in the storefront endpoints (#2980)

This commit is contained in:
Frane Polić
2023-02-06 16:18:23 +01:00
committed by GitHub
parent 4339d47e1f
commit e22a383f47
15 changed files with 308 additions and 64 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
fix(medusa): `fields` param in store products/orders endpoints

View File

@@ -155,6 +155,58 @@ describe("/store/carts", () => {
)
})
it("lookup order response contains only fields defined with `fields` param", async () => {
const api = useApi()
const response = await api
.get(
"/store/orders?display_id=111&email=test@email.com&fields=status,object"
)
.catch((err) => {
return err.response
})
expect(Object.keys(response.data.order)).toEqual([
// fields
"status",
"object",
// relations
"shipping_address",
"fulfillments",
"items",
"shipping_methods",
"discounts",
"customer",
"payments",
"region",
])
})
it("get order response contains only fields defined with `fields` param", async () => {
const api = useApi()
const response = await api
.get("/store/orders/order_test?fields=status,object")
.catch((err) => {
return err.response
})
expect(Object.keys(response.data.order)).toEqual([
// fields
"status",
"object",
// relations
"shipping_address",
"fulfillments",
"items",
"shipping_methods",
"discounts",
"customer",
"payments",
"region",
])
})
it("looks up order", async () => {
const api = useApi()

View File

@@ -5,7 +5,7 @@ const { initDb, useDb } = require("../../../helpers/use-db")
const {
simpleProductFactory,
simpleProductCategoryFactory
simpleProductCategoryFactory,
} = require("../../factories")
const productSeeder = require("../../helpers/store-product-seeder")
@@ -194,6 +194,26 @@ describe("/store/products", () => {
expect(testProduct2Index).toBe(2) // 200
})
it("products contain only fields defined with `fields` param", async () => {
const api = useApi()
const response = await api.get("/store/products?fields=handle")
expect(response.status).toEqual(200)
expect(Object.keys(response.data.products[0])).toEqual([
// fields
"handle",
// relations
"variants",
"options",
"images",
"tags",
"collection",
"type",
])
})
it("returns a list of ordered products by id ASC and filtered with free text search", async () => {
const api = useApi()
@@ -455,7 +475,10 @@ describe("/store/products", () => {
})
describe("Product Category filtering", () => {
let categoryWithProduct, categoryWithoutProduct, nestedCategoryWithProduct, nested2CategoryWithProduct
let categoryWithProduct,
categoryWithoutProduct,
nestedCategoryWithProduct,
nested2CategoryWithProduct
const nestedCategoryWithProductId = "nested-category-with-product-id"
const nested2CategoryWithProductId = "nested2-category-with-product-id"
const categoryWithProductId = "category-with-product-id"
@@ -463,14 +486,11 @@ describe("/store/products", () => {
beforeEach(async () => {
const manager = dbConnection.manager
categoryWithProduct = await simpleProductCategoryFactory(
dbConnection,
{
id: categoryWithProductId,
name: "category with Product",
products: [{ id: testProductId }],
}
)
categoryWithProduct = await simpleProductCategoryFactory(dbConnection, {
id: categoryWithProductId,
name: "category with Product",
products: [{ id: testProductId }],
})
nestedCategoryWithProduct = await simpleProductCategoryFactory(
dbConnection,
@@ -504,49 +524,36 @@ describe("/store/products", () => {
it("returns a list of products in product category without category children", async () => {
const api = useApi()
const params = `category_id[]=${categoryWithProductId}`
const response = await api
.get(
`/store/products?${params}`,
)
const response = await api.get(`/store/products?${params}`)
expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(1)
expect(response.data.products).toEqual(
[
expect.objectContaining({
id: testProductId,
}),
]
)
expect(response.data.products).toEqual([
expect.objectContaining({
id: testProductId,
}),
])
})
it("returns a list of products in product category without category children explicitly set to false", async () => {
const api = useApi()
const params = `category_id[]=${categoryWithProductId}&include_category_children=false`
const response = await api
.get(
`/store/products?${params}`,
)
const response = await api.get(`/store/products?${params}`)
expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(1)
expect(response.data.products).toEqual(
[
expect.objectContaining({
id: testProductId,
}),
]
)
expect(response.data.products).toEqual([
expect.objectContaining({
id: testProductId,
}),
])
})
it("returns a list of products in product category with category children", async () => {
const api = useApi()
const params = `category_id[]=${categoryWithProductId}&include_category_children=true`
const response = await api
.get(
`/store/products?${params}`,
)
const response = await api.get(`/store/products?${params}`)
expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(3)
@@ -560,7 +567,7 @@ describe("/store/products", () => {
}),
expect.objectContaining({
id: testProductFilteringId1,
})
}),
])
)
})
@@ -569,10 +576,7 @@ describe("/store/products", () => {
const api = useApi()
const params = `category_id[]=${categoryWithoutProductId}&include_category_children=true`
const response = await api
.get(
`/store/products?${params}`,
)
const response = await api.get(`/store/products?${params}`)
expect(response.status).toEqual(200)
expect(response.data.products).toHaveLength(0)
@@ -1082,5 +1086,27 @@ describe("/store/products", () => {
])
)
})
it("response contains only fields defined with `fields` param", async () => {
const api = useApi()
const response = await api.get(
"/store/products/test-product?fields=handle"
)
expect(response.status).toEqual(200)
expect(Object.keys(response.data.product)).toEqual([
// fields
"handle",
// relations
"variants",
"options",
"images",
"tags",
"collection",
"type",
])
})
})
})

View File

@@ -39,6 +39,20 @@ export function transformQuery<
])
req.filterableFields = removeUndefinedProperties(req.filterableFields)
if (
(queryConfig?.defaultFields || validated.fields) &&
(queryConfig?.defaultRelations || validated.expand)
) {
req.allowedProperties = [
...(validated.fields
? validated.fields.split(",")
: queryConfig?.allowedFields || [])!,
...(validated.expand
? validated.expand.split(",")
: queryConfig?.allowedRelations || [])!,
] as unknown as string[]
}
if (queryConfig?.isList) {
req.listConfig = prepareListQuery(
validated,

View File

@@ -6,7 +6,7 @@ import GiftCardService from "../../../../services/gift-card"
* @oas [get] /gift-cards/{code}
* operationId: "GetGiftCardsCode"
* summary: "Get Gift Card by Code"
* description: "Retrieves a Gift Card by its associated unqiue code."
* description: "Retrieves a Gift Card by its associated unique code."
* parameters:
* - (path) code=* {string} The unique Gift Card code.
* x-codegen:

View File

@@ -1,6 +1,8 @@
import { defaultStoreOrdersFields, defaultStoreOrdersRelations } from "./index"
import { OrderService } from "../../../../services"
import { FindParams } from "../../../../types/common"
import { cleanResponseData } from "../../../../utils/clean-response-data"
/**
* @oas [get] /orders/{id}
@@ -9,6 +11,8 @@ import { OrderService } from "../../../../services"
* description: "Retrieves an Order"
* parameters:
* - (path) id=* {string} The id of the Order.
* - (query) fields {string} (Comma separated) Which fields should be included in the result.
* - (query) expand {string} (Comma separated) Which fields should be expanded in the result.
* x-codegen:
* method: retrieve
* x-codeSamples:
@@ -54,5 +58,9 @@ export default async (req, res) => {
relations: defaultStoreOrdersRelations,
})
res.json({ order })
res.json({
order: cleanResponseData(order, req.allowedProperties || []),
})
}
export class StoreGetOrderParams extends FindParams {}

View File

@@ -1,10 +1,15 @@
import { Router } from "express"
import "reflect-metadata"
import { Order } from "../../../.."
import middlewares, { transformBody } from "../../../middlewares"
import middlewares, {
transformBody,
transformQuery,
} from "../../../middlewares"
import requireCustomerAuthentication from "../../../middlewares/require-customer-authentication"
import { StorePostCustomersCustomerOrderClaimReq } from "./request-order"
import { StorePostCustomersCustomerAcceptClaimReq } from "./confirm-order-request"
import { StoreGetOrderParams } from "./get-order"
import { StoreGetOrdersParams } from "./lookup-order"
const route = Router()
@@ -14,12 +19,31 @@ export default (app) => {
/**
* Lookup
*/
route.get("/", middlewares.wrap(require("./lookup-order").default))
route.get(
"/",
transformQuery(StoreGetOrdersParams, {
defaultFields: defaultStoreOrdersFields,
defaultRelations: defaultStoreOrdersRelations,
allowedFields: allowedStoreOrdersFields,
allowedRelations: allowedStoreOrdersRelations,
isList: true,
}),
middlewares.wrap(require("./lookup-order").default)
)
/**
* Retrieve Order
*/
route.get("/:id", middlewares.wrap(require("./get-order").default))
route.get(
"/:id",
transformQuery(StoreGetOrderParams, {
defaultFields: defaultStoreOrdersFields,
defaultRelations: defaultStoreOrdersRelations,
allowedFields: allowedStoreOrdersFields,
allowedRelations: allowedStoreOrdersRelations,
}),
middlewares.wrap(require("./get-order").default)
)
/**
* Retrieve by Cart Id
@@ -60,6 +84,11 @@ export const defaultStoreOrdersRelations = [
"region",
]
export const allowedStoreOrdersRelations = [
...defaultStoreOrdersRelations,
"billing_address",
]
export const defaultStoreOrdersFields = [
"id",
"status",
@@ -83,6 +112,21 @@ export const defaultStoreOrdersFields = [
"total",
] as (keyof Order)[]
export const allowedStoreOrdersFields = [
...defaultStoreOrdersFields,
"object",
"shipping_total",
"discount_total",
"tax_total",
"refunded_total",
"total",
"subtotal",
"paid_total",
"refundable_amount",
"gift_card_total",
"gift_card_tax_total",
]
/**
* @schema StoreOrdersRes
* type: object

View File

@@ -5,11 +5,13 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { defaultStoreOrdersFields, defaultStoreOrdersRelations } from "."
import { Type } from "class-transformer"
import { OrderService } from "../../../../services"
import { Type } from "class-transformer"
import { validator } from "../../../../utils/validator"
import { cleanResponseData } from "../../../../utils/clean-response-data"
import { defaultStoreOrdersFields, defaultStoreOrdersRelations } from "."
import { FindParams } from "../../../../types/common"
/**
* @oas [get] /orders
@@ -18,6 +20,8 @@ import { validator } from "../../../../utils/validator"
* description: "Look up an order using filters."
* parameters:
* - (query) display_id=* {number} The display id given to the Order.
* - (query) fields {string} (Comma separated) Which fields should be included in the result.
* - (query) expand {string} (Comma separated) Which fields should be expanded in the result.
* - in: query
* name: email
* style: form
@@ -79,7 +83,7 @@ import { validator } from "../../../../utils/validator"
* $ref: "#/components/responses/500_error"
*/
export default async (req, res) => {
const validated = await validator(StoreGetOrdersParams, req.query)
const validated = req.validatedQuery as StoreGetOrdersParams
const orderService: OrderService = req.scope.resolve("orderService")
@@ -101,7 +105,7 @@ export default async (req, res) => {
const order = orders[0]
res.json({ order })
res.json({ order: cleanResponseData(order, req.allowedProperties || []) })
}
export class ShippingAddressPayload {
@@ -110,7 +114,7 @@ export class ShippingAddressPayload {
postal_code?: string
}
export class StoreGetOrdersParams {
export class StoreGetOrdersParams extends FindParams {
@IsNumber()
@Type(() => Number)
display_id: number

View File

@@ -20,7 +20,7 @@ export default (app, container) => {
"/:id",
transformQuery(GetPaymentCollectionsParams, {
defaultFields: defaultPaymentCollectionFields,
defaultRelations: defaulPaymentCollectionRelations,
defaultRelations: defaultPaymentCollectionRelations,
isList: false,
}),
middlewares.wrap(require("./get-payment-collection").default)
@@ -69,7 +69,7 @@ export const defaultPaymentCollectionFields = [
"metadata",
]
export const defaulPaymentCollectionRelations = ["region", "payment_sessions"]
export const defaultPaymentCollectionRelations = ["region", "payment_sessions"]
/**
* @schema StorePaymentCollectionsRes

View File

@@ -1,5 +1,5 @@
import { IdMap } from "medusa-test-utils"
import { defaultStoreProductsRelations } from ".."
import { defaultStoreProductsFields, defaultStoreProductsRelations } from ".."
import { request } from "../../../../../helpers/test-request"
import { ProductServiceMock } from "../../../../../services/__mocks__/product"
@@ -21,9 +21,9 @@ describe("GET /store/products", () => {
{ status: ["published"] },
{
relations: defaultStoreProductsRelations,
select: defaultStoreProductsFields,
skip: 0,
take: 100,
select: undefined,
order: {
created_at: "DESC",
},
@@ -52,12 +52,12 @@ describe("GET /store/products", () => {
{ is_giftcard: true, status: ["published"] },
{
relations: defaultStoreProductsRelations,
select: defaultStoreProductsFields,
skip: 0,
take: 100,
order: {
created_at: "DESC",
},
select: undefined,
}
)
})

View File

@@ -10,7 +10,7 @@ import {
} from "../../../../services"
import { PriceSelectionParams } from "../../../../types/price-selection"
import { FlagRouter } from "../../../../utils/flag-router"
import { validator } from "../../../../utils/validator"
import { cleanResponseData } from "../../../../utils/clean-response-data"
/**
* @oas [get] /products/{id}
@@ -22,6 +22,8 @@ import { validator } from "../../../../utils/validator"
* - (query) sales_channel_id {string} The sales channel used when fetching the product.
* - (query) cart_id {string} The ID of the customer's cart.
* - (query) region_id {string} The ID of the region the customer is using. This is helpful to ensure correct prices are retrieved for a region.
* - (query) fields {string} (Comma separated) Which fields should be included in the result.
* - (query) expand {string} (Comma separated) Which fields should be expanded in each product of the result.
* - in: query
* name: currency_code
* style: form
@@ -72,7 +74,7 @@ import { validator } from "../../../../utils/validator"
export default async (req, res) => {
const { id } = req.params
const validated = await validator(StoreGetProductsProductParams, req.query)
const validated = req.validatedQuery as StoreGetProductsProductParams
const customer_id = req.user?.customer_id
@@ -123,11 +125,21 @@ export default async (req, res) => {
sales_channel_id
)
res.json({ product })
res.json({
product: cleanResponseData(product, req.allowedProperties || []),
})
}
export class StoreGetProductsProductParams extends PriceSelectionParams {
@IsString()
@IsOptional()
sales_channel_id?: string
@IsString()
@IsOptional()
fields?: string
@IsString()
@IsOptional()
expand?: string
}

View File

@@ -10,6 +10,7 @@ import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/pub
import { validateProductSalesChannelAssociation } from "../../../middlewares/publishable-api-key/validate-product-sales-channel-association"
import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param"
import { StoreGetProductsParams } from "./list-products"
import { StoreGetProductsProductParams } from "./get-product"
const route = Router()
@@ -29,11 +30,25 @@ export default (app, featureFlagRouter: FlagRouter) => {
"/",
transformQuery(StoreGetProductsParams, {
defaultRelations: defaultStoreProductsRelations,
defaultFields: defaultStoreProductsFields,
allowedFields: allowedStoreProductsFields,
allowedRelations: allowedStoreProductsRelations,
isList: true,
}),
middlewares.wrap(require("./list-products").default)
)
route.get("/:id", middlewares.wrap(require("./get-product").default))
route.get(
"/:id",
transformQuery(StoreGetProductsProductParams, {
defaultRelations: defaultStoreProductsRelations,
defaultFields: defaultStoreProductsFields,
allowedFields: allowedStoreProductsFields,
allowedRelations: allowedStoreProductsRelations,
}),
middlewares.wrap(require("./get-product").default)
)
route.post("/search", middlewares.wrap(require("./search").default))
return app
@@ -51,6 +66,47 @@ export const defaultStoreProductsRelations = [
"type",
]
export const defaultStoreProductsFields: (keyof Product)[] = [
"id",
"title",
"subtitle",
"status",
"external_id",
"description",
"handle",
"is_giftcard",
"discountable",
"thumbnail",
"profile_id",
"collection_id",
"type_id",
"weight",
"length",
"height",
"width",
"hs_code",
"origin_country",
"mid_code",
"material",
"created_at",
"updated_at",
"deleted_at",
"metadata",
]
export const allowedStoreProductsFields = [
...defaultStoreProductsFields,
// TODO: order prop validation
"variants.title",
"variants.prices.amount",
]
export const allowedStoreProductsRelations = [
...defaultStoreProductsRelations,
"variants.title",
"variants.prices.amount",
]
export * from "./list-products"
export * from "./search"

View File

@@ -22,6 +22,7 @@ import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
import { IsType } from "../../../../utils/validators/is-type"
import { FlagRouter } from "../../../../utils/flag-router"
import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys"
import { cleanResponseData } from "../../../../utils/clean-response-data"
/**
* @oas [get] /products
@@ -137,8 +138,8 @@ import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/pub
* - (query) include_category_children {boolean} Include category children when filtering by category_id.
* - (query) offset=0 {integer} How many products to skip in the result.
* - (query) limit=100 {integer} Limit the number of products returned.
* - (query) expand {string} (Comma separated) Which fields should be expanded in each order of the result.
* - (query) fields {string} (Comma separated) Which fields should be included in each order of the result.
* - (query) expand {string} (Comma separated) Which fields should be expanded in each product of the result.
* - (query) fields {string} (Comma separated) Which fields should be included in each product of the result.
* - (query) order {string} the field used to order the products.
* - (query) cart_id {string} The id of the Cart to set prices based on.
* - (query) region_id {string} The id of the Region to set prices based on.
@@ -241,7 +242,7 @@ export default async (req, res) => {
)
res.json({
products,
products: cleanResponseData(products, req.allowedProperties || []),
count,
offset: validated.offset,
limit: validated.limit,

View File

@@ -16,6 +16,7 @@ declare global {
listConfig: FindConfig<unknown>
retrieveConfig: FindConfig<unknown>
filterableFields: Record<string, unknown>
allowedProperties: string[]
errors: string[]
}
}

View File

@@ -0,0 +1,21 @@
import { pick } from "lodash"
/**
* Filter response data to contain props specified in the fields array.
*
* @param data - record or an array of records in the response
* @param fields - record props allowed in the response
*/
function cleanResponseData<T>(data: T, fields: string[]) {
if (!fields.length) {
return data
}
if (Array.isArray(data)) {
return data.map((record) => pick(record, fields))
}
return pick(data, fields)
}
export { cleanResponseData }