feat(medusa): Support transformQuery/Body middleware, introduction of pipe feature (#1593)

* feat(medusa): Support transformQuery middleware

* feat(medusa): Add support for transformBody

* test(medusa): Fix unit tests

* feat(medusa): Cleanup import

* feat(medusa): Update pipe required fields

* feat(medusa): Cleanup

* feat(medusa): Types cleanup

* feat(medusa): Types cleanup

* feat(medusa): Improve global typings and add one more example

* fix(medusa): Wrong wording in the error for prepareListQuery utility
This commit is contained in:
Adrien de Peretti
2022-06-10 16:25:09 +02:00
committed by GitHub
parent d011f38305
commit 3359e189a7
14 changed files with 240 additions and 166 deletions

View File

@@ -5,6 +5,8 @@ import { default as wrap } from "./await-middleware"
export { getRequestedBatchJob } from "./batch-job/get-requested-batch-job"
export { canAccessBatchJob } from "./batch-job/can-access-batch-job"
export { transformQuery } from "./transform-query"
export { transformBody } from "./transform-body"
export default {
authenticate,

View File

@@ -0,0 +1,18 @@
import { NextFunction, Request, Response } from "express"
import { ClassConstructor } from "../../types/global"
import { ValidatorOptions } from "class-validator"
import { validator } from "../../utils/validator"
export function transformBody<T>(
plainToClass: ClassConstructor<T>,
config: ValidatorOptions = {}
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
return async (req: Request, res: Response, next: NextFunction) => {
try {
req.validatedBody = await validator(plainToClass, req.body, config)
next()
} catch (e) {
next(e)
}
}
}

View File

@@ -0,0 +1,57 @@
import { NextFunction, Request, Response } from "express"
import { ClassConstructor } from "../../types/global"
import { validator } from "../../utils/validator"
import { ValidatorOptions } from "class-validator"
import { default as normalizeQuery } from "./normalized-query"
import {
prepareListQuery,
prepareRetrieveQuery,
} from "../../utils/get-query-config"
import { BaseEntity } from "../../interfaces/models/base-entity"
import { FindConfig, QueryConfig, RequestQueryFields } from "../../types/common"
import { omit } from "lodash"
export function transformQuery<
T extends RequestQueryFields,
TEntity extends BaseEntity
>(
plainToClass: ClassConstructor<T>,
queryConfig?: QueryConfig<TEntity>,
config: ValidatorOptions = {}
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
return async (req: Request, res: Response, next: NextFunction) => {
try {
normalizeQuery()(req, res, () => void 0)
const validated: T = await validator<T, Record<string, unknown>>(
plainToClass,
req.query,
config
)
req.validatedQuery = validated
req.filterableFields = omit(validated, [
"limit",
"offset",
"expand",
"fields",
"order",
])
if (queryConfig?.isList) {
req.listConfig = prepareListQuery(
validated,
queryConfig
) as FindConfig<unknown>
} else {
req.retrieveConfig = prepareRetrieveQuery(
validated,
queryConfig
) as FindConfig<unknown>
}
next()
} catch (e) {
next(e)
}
}
}

View File

@@ -1,7 +1,8 @@
import { Router } from "express"
import { BatchJob } from "../../../.."
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares"
import middlewares, { transformQuery } from "../../../middlewares"
import { AdminGetBatchParams } from "./list-batch-jobs"
export default (app) => {
const route = Router()
@@ -10,7 +11,10 @@ export default (app) => {
route.get(
"/",
middlewares.normalizeQuery(),
transformQuery(AdminGetBatchParams, {
defaultFields: defaultAdminBatchFields,
isList: true,
}),
middlewares.wrap(require("./list-batch-jobs").default)
)
return app

View File

@@ -1,4 +1,3 @@
import { MedusaError } from "medusa-core-utils"
import { Type } from "class-transformer"
import {
IsArray,
@@ -8,15 +7,12 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { pickBy, omit, identity } from "lodash"
import { defaultAdminBatchFields } from "."
import { pickBy } from "lodash"
import BatchJobService from "../../../../services/batch-job"
import { BatchJob } from "../../../../models"
import { BatchJobStatus } from "../../../../types/batch-job"
import { DateComparisonOperator } from "../../../../types/common"
import { IsType } from "../../../../utils/validators/is-type"
import { getListConfig } from "../../../../utils/get-query-config"
import { validator } from "../../../../utils/validator"
import { Request } from "express"
/**
* @oas [get] /batch
@@ -47,47 +43,25 @@ import { validator } from "../../../../utils/validator"
* batch_job:
* $ref: "#/components/schemas/batch_job"
*/
export default async (req, res) => {
const { fields, expand, order, limit, offset, ...filterableFields } =
await validator(AdminGetBatchParams, req.query)
export default async (req: Request, res) => {
const batchService: BatchJobService = req.scope.resolve("batchJobService")
let orderBy: { [k: symbol]: "DESC" | "ASC" } | undefined
if (typeof order !== "undefined") {
if (order.startsWith("-")) {
const [, field] = order.split("-")
orderBy = { [field]: "DESC" }
} else {
orderBy = { [order]: "ASC" }
}
}
const listConfig = getListConfig<BatchJob>(
defaultAdminBatchFields as (keyof BatchJob)[],
[],
fields?.split(",") as (keyof BatchJob)[],
expand?.split(","),
limit,
offset,
orderBy
)
const created_by: string = req.user.id || req.user.userId
const created_by = req.user?.id || req.user?.userId
const [jobs, count] = await batchService.listAndCount(
pickBy(
{ created_by, ...filterableFields },
{ created_by, ...(req.filterableFields ?? {}) },
(val) => typeof val !== "undefined"
),
listConfig
req.listConfig
)
const { limit, offset } = req.validatedQuery
res.status(200).json({
batch_jobs: jobs,
count,
offset: offset,
limit: limit,
offset,
limit,
})
}

View File

@@ -8,11 +8,11 @@ import {
} from "class-validator"
import PriceListService from "../../../../services/price-list"
import {
AdminPriceListPricesCreateReq,
AdminPriceListPricesCreateReq, CreatePriceListInput,
PriceListStatus,
PriceListType,
} from "../../../../types/price-list"
import { validator } from "../../../../utils/validator"
import { Request } from "express"
/**
* @oas [post] /price_lists
@@ -85,13 +85,13 @@ import { validator } from "../../../../utils/validator"
* product:
* $ref: "#/components/schemas/price_list"
*/
export default async (req, res) => {
const validated = await validator(AdminPostPriceListsPriceListReq, req.body)
export default async (req: Request, res) => {
const priceListService: PriceListService =
req.scope.resolve("priceListService")
const priceList = await priceListService.create(validated)
const priceList = await priceListService.create(
req.validatedBody as CreatePriceListInput
)
res.json({ price_list: priceList })
}

View File

@@ -2,7 +2,18 @@ import { Router } from "express"
import "reflect-metadata"
import { PriceList } from "../../../.."
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares"
import middlewares, {
transformQuery,
transformBody,
} from "../../../middlewares"
import { AdminGetPriceListPaginationParams } from "./list-price-lists"
import { AdminGetPriceListsPriceListProductsParams } from "./list-price-list-products"
import {
allowedAdminProductFields,
defaultAdminProductFields,
defaultAdminProductRelations,
} from "../products"
import { AdminPostPriceListsPriceListReq } from "./create-price-list"
const route = Router()
@@ -13,12 +24,21 @@ export default (app) => {
route.get(
"/",
middlewares.normalizeQuery(),
transformQuery(AdminGetPriceListPaginationParams, { isList: true }),
middlewares.wrap(require("./list-price-lists").default)
)
route.get(
"/:id/products",
transformQuery(AdminGetPriceListsPriceListProductsParams, {
allowedFields: allowedAdminProductFields,
defaultFields: defaultAdminProductFields,
defaultRelations: defaultAdminProductRelations.filter(
(r) => r !== "variants.prices"
),
defaultLimit: 50,
isList: true,
}),
middlewares.wrap(require("./list-price-list-products").default)
)
@@ -31,7 +51,11 @@ export default (app) => {
middlewares.wrap(require("./delete-variant-prices").default)
)
route.post("/", middlewares.wrap(require("./create-price-list").default))
route.post(
"/",
transformBody(AdminPostPriceListsPriceListReq),
middlewares.wrap(require("./create-price-list").default)
)
route.post("/:id", middlewares.wrap(require("./update-price-list").default))

View File

@@ -1,5 +1,5 @@
import { Type } from "class-transformer"
import { omit, pickBy } from "lodash"
import { pickBy } from "lodash"
import {
IsArray,
IsBoolean,
@@ -8,19 +8,11 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { Product } from "../../../../models"
import { DateComparisonOperator } from "../../../../types/common"
import { validator } from "../../../../utils/validator"
import { FilterableProductProps } from "../../../../types/product"
import {
AdminGetProductsPaginationParams,
allowedAdminProductFields,
defaultAdminProductFields,
defaultAdminProductRelations,
} from "../products"
import { MedusaError } from "medusa-core-utils"
import { getListConfig } from "../../../../utils/get-query-config"
import { AdminGetProductsPaginationParams } from "../products"
import PriceListService from "../../../../services/price-list"
import { Request } from "express"
/**
* @oas [get] /price-lists/:id/products
@@ -70,81 +62,22 @@ import PriceListService from "../../../../services/price-list"
* items:
* $ref: "#/components/schemas/product"
*/
export default async (req, res) => {
export default async (req: Request, res) => {
const { id } = req.params
const validatedParams = await validator(
AdminGetPriceListsPriceListProductsParams,
req.query
)
req.query.price_list_id = [id]
const query: FilterableProductProps = omit(req.query, [
"limit",
"offset",
"expand",
"fields",
"order",
])
const limit = validatedParams.limit ?? 50
const offset = validatedParams.offset ?? 0
const expand = validatedParams.expand
const fields = validatedParams.fields
const order = validatedParams.order
const allowedFields = allowedAdminProductFields
const defaultFields = defaultAdminProductFields as (keyof Product)[]
const defaultRelations = defaultAdminProductRelations.filter(
(r) => r !== "variants.prices"
)
const { offset, limit } = req.validatedQuery
const priceListService: PriceListService =
req.scope.resolve("priceListService")
let includeFields: (keyof Product)[] | undefined
if (fields) {
includeFields = fields.split(",") as (keyof Product)[]
const filterableFields: FilterableProductProps = {
...req.filterableFields,
price_list_id: [id],
}
let expandFields: string[] | undefined
if (expand) {
expandFields = expand.split(",")
}
let orderBy: { [k: symbol]: "DESC" | "ASC" } | undefined
if (typeof order !== "undefined") {
let orderField = order
if (order.startsWith("-")) {
const [, field] = order.split("-")
orderField = field
orderBy = { [field]: "DESC" }
} else {
orderBy = { [order]: "ASC" }
}
if (!(allowedFields || []).includes(orderField)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Order field must be a valid product field"
)
}
}
const listConfig = getListConfig<Product>(
defaultFields ?? [],
defaultRelations ?? [],
includeFields,
expandFields,
limit,
offset,
orderBy
)
const [products, count] = await priceListService.listProducts(
id,
pickBy(query, (val) => typeof val !== "undefined"),
listConfig
pickBy(filterableFields, (val) => typeof val !== "undefined"),
req.listConfig
)
res.json({

View File

@@ -6,6 +6,7 @@ import PriceListService from "../../../../services/price-list"
import { FindConfig } from "../../../../types/common"
import { FilterablePriceListProps } from "../../../../types/price-list"
import { validator } from "../../../../utils/validator"
import { Request } from "express"
/**
* @oas [get] /price-lists
* operationId: "GetPriceLists"
@@ -35,46 +36,15 @@ import { validator } from "../../../../utils/validator"
* description: The limit of the Price List query.
* type: integer
*/
export default async (req, res) => {
const validated = await validator(
AdminGetPriceListPaginationParams,
req.query
)
export default async (req: Request, res) => {
const validated = req.validatedQuery
const priceListService: PriceListService =
req.scope.resolve("priceListService")
let expandFields: string[] = []
if (validated.expand) {
expandFields = validated.expand.split(",")
}
const listConfig: FindConfig<FilterablePriceListProps> = {
relations: expandFields,
skip: validated.offset,
take: validated.limit,
order: { created_at: "DESC" } as { [k: string]: "DESC" },
}
if (typeof validated.order !== "undefined") {
if (validated.order.startsWith("-")) {
const [, field] = validated.order.split("-")
listConfig.order = { [field]: "DESC" }
} else {
listConfig.order = { [validated.order]: "ASC" }
}
}
const filterableFields: FilterablePriceListProps = omit(validated, [
"limit",
"offset",
"expand",
"order",
])
const [price_lists, count] = await priceListService.listAndCount(
filterableFields,
listConfig
req.filterableFields,
req.listConfig
)
res.json({

View File

@@ -24,10 +24,10 @@ const listAndCount = async (
body?: object,
context: ListContext = { limit: 50, offset: 0 }
): Promise<AdminProductsListRes> => {
const productService: ProductService = scope.resolve("productService")
const { limit, offset, allowedFields, defaultFields, defaultRelations } =
context
const productService: ProductService = scope.resolve("productService")
let includeFields: (keyof Product)[] | undefined
if (context.fields) {
includeFields = context.fields.split(",") as (keyof Product)[]

View File

@@ -9,6 +9,7 @@ import {
import "reflect-metadata"
import { FindManyOptions, FindOperator, OrderByCondition } from "typeorm"
import { transformDate } from "../utils/validators/date-transform"
import { BaseEntity } from "../interfaces/models/base-entity"
/**
* Utility type used to remove some optional attributes (coming from K) from a type T
@@ -75,6 +76,22 @@ export interface CustomFindOptions<TModel, InKeys extends keyof TModel> {
take?: number
}
export type QueryConfig<TEntity extends BaseEntity> = {
defaultFields?: (keyof TEntity | string)[]
defaultRelations?: string[]
allowedFields?: string[]
defaultLimit?: number
isList?: boolean
}
export type RequestQueryFields = {
expand?: string
fields?: string
offset?: number
limit?: number
order?: string
}
export type PaginatedResponse = { limit: number; offset: number; count: number }
export type DeleteResponse = {

View File

@@ -2,13 +2,19 @@ import { AwilixContainer } from "awilix"
import { Logger as _Logger } from "winston"
import { LoggerOptions } from "typeorm"
import { Customer, User } from "../models"
import { FindConfig, RequestQueryFields } from "./common"
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
user?: User | Customer
user?: (User | Customer) & { userId?: string }
scope: MedusaContainer
validatedQuery: RequestQueryFields & Record<string, unknown>
validatedBody: unknown
listConfig: FindConfig<unknown>
retrieveConfig: FindConfig<unknown>
filterableFields: Record<string, unknown>
}
}
}

View File

@@ -124,6 +124,8 @@ export type CreatePriceListInput = {
status?: PriceListStatus
prices: AdminPriceListPricesCreateReq[]
customer_groups?: { id: string }[]
starts_at?: Date
ends_at?: Date
}
export type UpdatePriceListInput = Partial<

View File

@@ -1,10 +1,7 @@
import { pick } from "lodash"
import { FindConfig } from "../types/common"
type BaseEntity = {
id: string
created_at: Date
}
import { FindConfig, QueryConfig, RequestQueryFields } from "../types/common"
import { MedusaError } from "medusa-core-utils/dist"
import { BaseEntity } from "../interfaces/models/base-entity"
export function pickByConfig<TModel extends BaseEntity>(
obj: TModel | TModel[],
@@ -81,3 +78,73 @@ export function getListConfig<TModel extends BaseEntity>(
order: orderBy,
}
}
export function prepareListQuery<
T extends RequestQueryFields,
TEntity extends BaseEntity
>(validated: T, queryConfig?: QueryConfig<TEntity>) {
const { order, fields, expand, limit, offset } = validated
let expandRelations: string[] | undefined = undefined
if (expand) {
expandRelations = expand.split(",")
}
let expandFields: (keyof TEntity)[] | undefined = undefined
if (fields) {
expandFields = fields.split(",") as (keyof TEntity)[]
}
let orderBy: { [k: symbol]: "DESC" | "ASC" } | undefined
if (typeof order !== "undefined") {
let orderField = order
if (order.startsWith("-")) {
const [, field] = order.split("-")
orderField = field
orderBy = { [field]: "DESC" }
} else {
orderBy = { [order]: "ASC" }
}
if (queryConfig?.allowedFields?.length && !queryConfig?.allowedFields.includes(orderField)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Order field ${orderField} is not valid`
)
}
}
return getListConfig<TEntity>(
queryConfig?.defaultFields as (keyof TEntity)[],
(queryConfig?.defaultRelations ?? []) as string[],
expandFields,
expandRelations,
limit ?? queryConfig?.defaultLimit,
offset ?? 0,
orderBy
)
}
export function prepareRetrieveQuery<
T extends RequestQueryFields,
TEntity extends BaseEntity
>(validated: T, queryConfig?: QueryConfig<TEntity>) {
const { fields, expand } = validated
let expandRelations: string[] = []
if (expand) {
expandRelations = expand.split(",")
}
let expandFields: (keyof TEntity)[] = []
if (fields) {
expandFields = fields.split(",") as (keyof TEntity)[]
}
return getRetrieveConfig<TEntity>(
queryConfig?.defaultFields as (keyof TEntity)[],
(queryConfig?.defaultRelations ?? []) as string[],
expandFields,
expandRelations
)
}