From 3359e189a70533692f85fbbff9b09018872abbf4 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Fri, 10 Jun 2022 16:25:09 +0200 Subject: [PATCH] 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 --- packages/medusa/src/api/middlewares/index.ts | 2 + .../src/api/middlewares/transform-body.ts | 18 ++++ .../src/api/middlewares/transform-query.ts | 57 ++++++++++++ .../src/api/routes/admin/batch/index.ts | 8 +- .../api/routes/admin/batch/list-batch-jobs.ts | 44 ++-------- .../admin/price-lists/create-price-list.ts | 12 +-- .../src/api/routes/admin/price-lists/index.ts | 30 ++++++- .../price-lists/list-price-list-products.ts | 87 +++---------------- .../admin/price-lists/list-price-lists.ts | 40 ++------- .../products/admin-list-products.ts | 2 +- packages/medusa/src/types/common.ts | 17 ++++ packages/medusa/src/types/global.ts | 8 +- packages/medusa/src/types/price-list.ts | 2 + packages/medusa/src/utils/get-query-config.ts | 79 +++++++++++++++-- 14 files changed, 240 insertions(+), 166 deletions(-) create mode 100644 packages/medusa/src/api/middlewares/transform-body.ts create mode 100644 packages/medusa/src/api/middlewares/transform-query.ts diff --git a/packages/medusa/src/api/middlewares/index.ts b/packages/medusa/src/api/middlewares/index.ts index 5844904f49..08d79c2701 100644 --- a/packages/medusa/src/api/middlewares/index.ts +++ b/packages/medusa/src/api/middlewares/index.ts @@ -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, diff --git a/packages/medusa/src/api/middlewares/transform-body.ts b/packages/medusa/src/api/middlewares/transform-body.ts new file mode 100644 index 0000000000..bdbc0d5908 --- /dev/null +++ b/packages/medusa/src/api/middlewares/transform-body.ts @@ -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( + plainToClass: ClassConstructor, + config: ValidatorOptions = {} +): (req: Request, res: Response, next: NextFunction) => Promise { + return async (req: Request, res: Response, next: NextFunction) => { + try { + req.validatedBody = await validator(plainToClass, req.body, config) + next() + } catch (e) { + next(e) + } + } +} diff --git a/packages/medusa/src/api/middlewares/transform-query.ts b/packages/medusa/src/api/middlewares/transform-query.ts new file mode 100644 index 0000000000..142c2f55d7 --- /dev/null +++ b/packages/medusa/src/api/middlewares/transform-query.ts @@ -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, + queryConfig?: QueryConfig, + config: ValidatorOptions = {} +): (req: Request, res: Response, next: NextFunction) => Promise { + return async (req: Request, res: Response, next: NextFunction) => { + try { + normalizeQuery()(req, res, () => void 0) + const validated: T = await validator>( + 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 + } else { + req.retrieveConfig = prepareRetrieveQuery( + validated, + queryConfig + ) as FindConfig + } + + next() + } catch (e) { + next(e) + } + } +} diff --git a/packages/medusa/src/api/routes/admin/batch/index.ts b/packages/medusa/src/api/routes/admin/batch/index.ts index af5f81e7b5..8fba2f055f 100644 --- a/packages/medusa/src/api/routes/admin/batch/index.ts +++ b/packages/medusa/src/api/routes/admin/batch/index.ts @@ -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 diff --git a/packages/medusa/src/api/routes/admin/batch/list-batch-jobs.ts b/packages/medusa/src/api/routes/admin/batch/list-batch-jobs.ts index 432849e4b2..50981e99d1 100644 --- a/packages/medusa/src/api/routes/admin/batch/list-batch-jobs.ts +++ b/packages/medusa/src/api/routes/admin/batch/list-batch-jobs.ts @@ -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( - 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, }) } diff --git a/packages/medusa/src/api/routes/admin/price-lists/create-price-list.ts b/packages/medusa/src/api/routes/admin/price-lists/create-price-list.ts index 5372b3c81c..15c1b0e848 100644 --- a/packages/medusa/src/api/routes/admin/price-lists/create-price-list.ts +++ b/packages/medusa/src/api/routes/admin/price-lists/create-price-list.ts @@ -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 }) } diff --git a/packages/medusa/src/api/routes/admin/price-lists/index.ts b/packages/medusa/src/api/routes/admin/price-lists/index.ts index ef10783591..a77bdc0647 100644 --- a/packages/medusa/src/api/routes/admin/price-lists/index.ts +++ b/packages/medusa/src/api/routes/admin/price-lists/index.ts @@ -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)) diff --git a/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts b/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts index 6b4c6de109..f62aa3eaa2 100644 --- a/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts +++ b/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts @@ -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( - 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({ diff --git a/packages/medusa/src/api/routes/admin/price-lists/list-price-lists.ts b/packages/medusa/src/api/routes/admin/price-lists/list-price-lists.ts index 61a9ee2aec..437119ffc5 100644 --- a/packages/medusa/src/api/routes/admin/price-lists/list-price-lists.ts +++ b/packages/medusa/src/api/routes/admin/price-lists/list-price-lists.ts @@ -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 = { - 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({ diff --git a/packages/medusa/src/controllers/products/admin-list-products.ts b/packages/medusa/src/controllers/products/admin-list-products.ts index 9c37eadc09..19011efd11 100644 --- a/packages/medusa/src/controllers/products/admin-list-products.ts +++ b/packages/medusa/src/controllers/products/admin-list-products.ts @@ -24,10 +24,10 @@ const listAndCount = async ( body?: object, context: ListContext = { limit: 50, offset: 0 } ): Promise => { + 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)[] diff --git a/packages/medusa/src/types/common.ts b/packages/medusa/src/types/common.ts index d0b0aaf640..6980b044af 100644 --- a/packages/medusa/src/types/common.ts +++ b/packages/medusa/src/types/common.ts @@ -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 { take?: number } +export type QueryConfig = { + 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 = { diff --git a/packages/medusa/src/types/global.ts b/packages/medusa/src/types/global.ts index 25b9388560..50436d4ea3 100644 --- a/packages/medusa/src/types/global.ts +++ b/packages/medusa/src/types/global.ts @@ -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 + validatedBody: unknown + listConfig: FindConfig + retrieveConfig: FindConfig + filterableFields: Record } } } diff --git a/packages/medusa/src/types/price-list.ts b/packages/medusa/src/types/price-list.ts index bee6195c88..0215ab3043 100644 --- a/packages/medusa/src/types/price-list.ts +++ b/packages/medusa/src/types/price-list.ts @@ -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< diff --git a/packages/medusa/src/utils/get-query-config.ts b/packages/medusa/src/utils/get-query-config.ts index b7c5e72008..16398c1a8e 100644 --- a/packages/medusa/src/utils/get-query-config.ts +++ b/packages/medusa/src/utils/get-query-config.ts @@ -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( obj: TModel | TModel[], @@ -81,3 +78,73 @@ export function getListConfig( order: orderBy, } } + +export function prepareListQuery< + T extends RequestQueryFields, + TEntity extends BaseEntity +>(validated: T, queryConfig?: QueryConfig) { + 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( + 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) { + 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( + queryConfig?.defaultFields as (keyof TEntity)[], + (queryConfig?.defaultRelations ?? []) as string[], + expandFields, + expandRelations + ) +}