Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(medusa): Retrieve (service + controller) a product category #3004

Merged
merged 7 commits into from
Jan 12, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/empty-beers-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---

feat(medusa): create a store endpoint to retrieve a product category
109 changes: 109 additions & 0 deletions integration-tests/api/__tests__/store/product-category.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import path from "path"

import startServerWithEnvironment from "../../../helpers/start-server-with-environment"
import { useApi } from "../../../helpers/use-api"
import { useDb } from "../../../helpers/use-db"
import { simpleProductCategoryFactory } from "../../factories"

jest.setTimeout(30000)

describe("/store/product-categories", () => {
let medusaProcess
let dbConnection
let productCategory = null
let productCategory2 = null
let productCategoryChild = null
let productCategoryParent = null
let productCategoryChild2 = null

beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_PRODUCT_CATEGORIES: true },
})
dbConnection = connection
medusaProcess = process
})

afterAll(async () => {
const db = useDb()
await db.shutdown()

medusaProcess.kill()
})

describe("GET /store/product-categories/:id", () => {
riqwan marked this conversation as resolved.
Show resolved Hide resolved
beforeEach(async () => {
productCategoryParent = await simpleProductCategoryFactory(dbConnection, {
name: "category parent",
handle: "category-parent",
})

productCategory = await simpleProductCategoryFactory(dbConnection, {
name: "category",
handle: "category",
parent_category: productCategoryParent,
})

productCategoryChild = await simpleProductCategoryFactory(dbConnection, {
name: "category child",
handle: "category-child",
parent_category: productCategory,
})

productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, {
name: "category child 2",
handle: "category-child-2",
parent_category: productCategoryChild,
})
})

afterEach(async () => {
const db = useDb()
return await db.teardown()
})

it("gets product category with children tree and parent", async () => {
const api = useApi()

const response = await api.get(
`/store/product-categories/${productCategory.id}?fields=handle,name`,
)

expect(response.data.product_category).toEqual(
expect.objectContaining({
id: productCategory.id,
handle: productCategory.handle,
name: productCategory.name,
parent_category: expect.objectContaining({
id: productCategoryParent.id,
handle: productCategoryParent.handle,
name: productCategoryParent.name,
}),
category_children: [
expect.objectContaining({
id: productCategoryChild.id,
handle: productCategoryChild.handle,
name: productCategoryChild.name,
})
]
})
)

expect(response.status).toEqual(200)
})

it("throws error on querying not allowed fields", async () => {
const api = useApi()

const error = await api.get(
`/store/product-categories/${productCategory.id}?fields=mpath`,
).catch(e => e)

expect(error.response.status).toEqual(400)
expect(error.response.data.type).toEqual('invalid_data')
expect(error.response.data.message).toEqual('Fields [mpath] are not valid')
})
})
})
2 changes: 2 additions & 0 deletions packages/medusa/src/api/routes/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import shippingOptionRoutes from "./shipping-options"
import swapRoutes from "./swaps"
import variantRoutes from "./variants"
import paymentCollectionRoutes from "./payment-collections"
import productCategoryRoutes from "./product-categories"
import { parseCorsOrigins } from "medusa-core-utils"

const route = Router()
Expand Down Expand Up @@ -52,6 +53,7 @@ export default (app, container, config) => {
giftCardRoutes(route)
returnReasonRoutes(route)
paymentCollectionRoutes(route)
productCategoryRoutes(route)

return app
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Request, Response } from "express"

import ProductCategoryService from "../../../../services/product-category"
import { FindParams } from "../../../../types/common"
import { transformTreeNodesWithConfig } from "../../../../utils/transformers/tree"
import { defaultStoreProductCategoryRelations } from "."

/**
* @oas [get] /product-categories/{id}
* operationId: "GetProductCategoriesCategory"
* summary: "Get a Product Category"
* description: "Retrieves a Product Category."
* x-authenticated: false
* parameters:
* - (path) id=* {string} The ID of the Product Category
* - (query) expand {string} (Comma separated) Which fields should be expanded in each product category.
* - (query) fields {string} (Comma separated) Which fields should be retrieved in each product category.
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.productCategories.retrieve("pcat-id")
* .then(({ productCategory }) => {
* console.log(productCategory.id);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request GET 'https://medusa-url.com/store/product-categories/{id}' \
* --header 'Authorization: Bearer {api_token}'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Product Category
* responses:
* "200":
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* productCategory:
* $ref: "#/components/schemas/ProductCategory"
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req: Request, res: Response) => {
const { id } = req.params
const { retrieveConfig } = req

const productCategoryService: ProductCategoryService = req.scope.resolve(
"productCategoryService"
)

const productCategory = await productCategoryService.retrieve(
id,
retrieveConfig
Copy link
Contributor Author

@riqwan riqwan Jan 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, now that i look at this, i do need to add a { where: { is_internal: false, is_active: true } } scope to the store endpoints. How is this typically done? Do i create a new repo method that accepts the extra scope arg? Because ExtendedFindConfig is used exclusively for list endpoints. @adrien2p @pKorsholm

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can create a method in the service retrieveActiveNonInternal or something like that (can't find a good name)

Copy link
Member

@adrien2p adrien2p Jan 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sometimes what I do is the following:

  • create a protected retrieve method that accept selector, config
  • create a retrieveById that accept the actual retrieve arguments, call the protected method
  • create a retrieveInternalActive, call the protected method

you can look at the product service as an example

Copy link
Contributor

@olivermrbl olivermrbl Jan 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to add fixed filter params for store endpoint instead of a dedicated method?

E.g. in /store/products we add "published" as a fixed filter in the controllers.

Copy link
Contributor

@olivermrbl olivermrbl Jan 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we shouldn't create dedicated retrieve methods for what is just a filter. That does not scale super well 🤔

Wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think creating a retrieve method that takes a selector (maybe string | selector, where string is just an id) as @adrien2p suggested is a good approach, then you could add the parameters as a where object.

In that way you get all the retrieveByEmail etc. we have elsewhere as well and can create the filter in the controller as Oli suggests 😄

)

res.status(200).json({
// TODO: When we implement custom queries for tree paths in medusa, remove the transformer
// Adding this here since typeorm tree repo doesn't allow configs to be passed
// onto its children nodes. As an alternative, we are transforming the data post query.
product_category: transformTreeNodesWithConfig(
riqwan marked this conversation as resolved.
Show resolved Hide resolved
productCategory,
retrieveConfig
),
})
}

export class StoreGetProductCategoryParams extends FindParams {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Router } from "express"
import middlewares, { transformQuery } from "../../../middlewares"
import getProductCategory, {
StoreGetProductCategoryParams,
} from "./get-product-category"

const route = Router()

export default (app) => {
app.use("/product-categories", route)

route.get(
"/:id",
transformQuery(StoreGetProductCategoryParams, {
defaultFields: defaultStoreProductCategoryFields,
allowedFields: allowedStoreProductCategoryFields,
defaultRelations: defaultStoreProductCategoryRelations,
isList: false,
}),
middlewares.wrap(getProductCategory)
)

return app
}

export const defaultStoreProductCategoryRelations = [
"parent_category",
"category_children",
]

export const defaultStoreProductCategoryFields = [
"id",
"name",
"handle",
"created_at",
"updated_at",
]

export const allowedStoreProductCategoryFields = [
"id",
"name",
"handle",
"created_at",
"updated_at",
]

export * from "./get-product-category"
25 changes: 25 additions & 0 deletions packages/medusa/src/utils/transformers/tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { pick } from "lodash"

// TODO: When we implement custom queries for tree paths in medusa, remove the transformer
// Adding this here since typeorm tree repo doesn't allow configs to be passed
// onto its children nodes. As an alternative, we are transforming the data post query.
export function transformTreeNodesWithConfig(object, config) {
const selects = (config.select || []) as string[]
const relations = (config.relations || []) as string[]
const selectsAndRelations = selects.concat(relations)

if (object.parent_category) {
riqwan marked this conversation as resolved.
Show resolved Hide resolved
object.parent_category = transformTreeNodesWithConfig(
object.parent_category,
config
)
}

if ((object.category_children || []).length > 0) {
object.category_children = object.category_children.map((child) => {
return transformTreeNodesWithConfig(child, config)
})
}

return pick(object, selectsAndRelations)
}