feat(medusa): PublishableApiKeys SC scopes (#2590)

**What**
-  add/ remove sales channels to/from PublishableApiKey in batch
- associate created cart with SC defined by PK
- filter products if PK with SC association is present
- retrieve a list of sales channels for a PK
- implement 3 new middleware
  - `extendRequestParams`
    -  _extend req object with PK scopes (a list of sales channels) if a publishable key is present in the header of the request_
  - `validateProductSalesChannelAssociation`
    -  _validate if the passed product id belongs to a SC from the PK's scope_
  - `validateSalesChannelParam`
    -  _validate that passed SC ids in the req body/query are within the scope of the PK_

**How**
- The general idea was to reuse existing logic in the controller layer which expects `sales_channel_id` array to be passed. The middleware sets associated SC ids in the request context if a PK is present. These ids are then merged in the controller and passed to the service layer.

**TODO**
- filter response from the search endpoint (CORE-824)

**Testing**
- _integration tests_
  - add sales channels to the publishable API key scope
  - remove sales channels from the publishable API key scope
  - returns products from a specific channel associated with a publishable key
  - returns products from multiples sales channels associated with a publishable key
  - returns all products if PK is not passed
  - returns all products if passed PK doesn't have associated channels
  - should assign sales channel to order on cart completion if PK is present in the header
  - list sales channels from the publishable api key
  -  throws because sales channel in query/body is not in the scope of passed PK 

---

**Discussion**
- what about the other endpoints (e.g. GET /store/product/:id - do we return 404 if the product is not in the SC associated with passed PK)
- what about products search route
- what about `/admin/products` & `/admin/orders` routes (do we add the middleware there as well)

---

RESOLVES CORE-792
RESOLVES CORE-793
RESOLVES CORE-816
This commit is contained in:
Frane Polić
2022-12-07 08:50:20 +01:00
committed by GitHub
parent 1b21af87ab
commit 322d462311
21 changed files with 1551 additions and 20 deletions

View File

@@ -9,6 +9,11 @@ const adminSeeder = require("../../helpers/admin-seeder")
const {
simplePublishableApiKeyFactory,
} = require("../../factories/simple-publishable-api-key-factory")
const {
simpleSalesChannelFactory,
simpleProductFactory,
simpleRegionFactory,
} = require("../../factories")
jest.setTimeout(50000)
@@ -18,6 +23,13 @@ const adminHeaders = {
},
}
const customerData = {
email: "medusa@test.hr",
password: "medusatest",
first_name: "medusa",
last_name: "medusa",
}
describe("[MEDUSA_FF_PUBLISHABLE_API_KEYS] Publishable API keys", () => {
let medusaProcess
let dbConnection
@@ -27,7 +39,10 @@ describe("[MEDUSA_FF_PUBLISHABLE_API_KEYS] Publishable API keys", () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_PUBLISHABLE_API_KEYS: true },
env: {
MEDUSA_FF_PUBLISHABLE_API_KEYS: true,
MEDUSA_FF_SALES_CHANNELS: true,
},
verbose: false,
})
dbConnection = connection
@@ -280,4 +295,777 @@ describe("[MEDUSA_FF_PUBLISHABLE_API_KEYS] Publishable API keys", () => {
}
})
})
describe("POST /admin/publishable-api-keys/:id/sales-channels/batch", () => {
const pubKeyId = IdMap.getId("pubkey-get-id-batch")
let salesChannel1
let salesChannel2
beforeEach(async () => {
await adminSeeder(dbConnection)
await simplePublishableApiKeyFactory(dbConnection, {
id: pubKeyId,
})
salesChannel1 = await simpleSalesChannelFactory(dbConnection, {
name: "test name",
description: "test description",
})
salesChannel2 = await simpleSalesChannelFactory(dbConnection, {
name: "test name 2",
description: "test description 2",
})
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("add sales channels to the publishable api key scope", async () => {
const api = useApi()
const response = await api.post(
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
{
sales_channel_ids: [
{ id: salesChannel1.id },
{ id: salesChannel2.id },
],
},
adminHeaders
)
const mappings = await dbConnection.manager.query(
`SELECT *
FROM publishable_api_key_sales_channel
WHERE publishable_key_id = '${pubKeyId}'`
)
expect(response.status).toBe(200)
expect(mappings).toEqual([
{
sales_channel_id: salesChannel1.id,
publishable_key_id: pubKeyId,
},
{
sales_channel_id: salesChannel2.id,
publishable_key_id: pubKeyId,
},
])
expect(response.data.publishable_api_key).toMatchObject({
id: pubKeyId,
created_at: expect.any(String),
updated_at: expect.any(String),
})
})
})
describe("DELETE /admin/publishable-api-keys/:id/sales-channels/batch", () => {
const pubKeyId = IdMap.getId("pubkey-get-id-batch-v2")
let salesChannel1
let salesChannel2
let salesChannel3
beforeEach(async () => {
await adminSeeder(dbConnection)
await simplePublishableApiKeyFactory(dbConnection, {
id: pubKeyId,
})
salesChannel1 = await simpleSalesChannelFactory(dbConnection, {
name: "test name",
description: "test description",
})
salesChannel2 = await simpleSalesChannelFactory(dbConnection, {
name: "test name 2",
description: "test description 2",
})
salesChannel3 = await simpleSalesChannelFactory(dbConnection, {
name: "test name 3",
description: "test description 3",
})
await dbConnection.manager.query(
`INSERT INTO
publishable_api_key_sales_channel
(publishable_key_id, sales_channel_id)
VALUES
('${pubKeyId}', '${salesChannel1.id}'),
('${pubKeyId}', '${salesChannel2.id}'),
('${pubKeyId}', '${salesChannel3.id}');`
)
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("remove sales channels from the publishable api key scope", async () => {
const api = useApi()
const response = await api.delete(
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
{
data: {
sales_channel_ids: [
{ id: salesChannel1.id },
{ id: salesChannel2.id },
],
},
...adminHeaders,
}
)
const mappings = await dbConnection.manager.query(
`SELECT *
FROM publishable_api_key_sales_channel
WHERE publishable_key_id = '${pubKeyId}'`
)
expect(response.status).toBe(200)
expect(mappings).toEqual([
{
sales_channel_id: salesChannel3.id,
publishable_key_id: pubKeyId,
},
])
expect(response.data.publishable_api_key).toMatchObject({
id: pubKeyId,
created_at: expect.any(String),
updated_at: expect.any(String),
})
})
})
describe("GET /admin/publishable-api-keys/:id/sales-channels", () => {
const pubKeyId = IdMap.getId("pubkey-get-id-batch-v2")
let salesChannel1
let salesChannel2
let salesChannel3
beforeEach(async () => {
await adminSeeder(dbConnection)
await simplePublishableApiKeyFactory(dbConnection, {
id: pubKeyId,
})
salesChannel1 = await simpleSalesChannelFactory(dbConnection, {
name: "test name",
description: "test description",
})
salesChannel2 = await simpleSalesChannelFactory(dbConnection, {
name: "test name 2",
description: "test description 2",
})
salesChannel3 = await simpleSalesChannelFactory(dbConnection, {
name: "test name 3",
description: "test description 3",
})
await dbConnection.manager.query(
`INSERT INTO
publishable_api_key_sales_channel
(publishable_key_id, sales_channel_id)
VALUES
('${pubKeyId}', '${salesChannel1.id}'),
('${pubKeyId}', '${salesChannel2.id}');`
)
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("list sales channels from the publishable api key", async () => {
const api = useApi()
const response = await api.get(
`/admin/publishable-api-keys/${pubKeyId}/sales-channels`,
adminHeaders
)
expect(response.status).toBe(200)
expect(response.data.sales_channels).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: salesChannel1.id,
deleted_at: null,
name: "test name",
description: "test description",
is_disabled: false,
}),
expect.objectContaining({
id: salesChannel2.id,
deleted_at: null,
name: "test name 2",
description: "test description 2",
is_disabled: false,
}),
])
)
})
})
describe("GET /store/products", () => {
const pubKeyId = IdMap.getId("pubkey-get-id")
let salesChannel1
let salesChannel2
let product1
let product2
let product3
beforeEach(async () => {
await adminSeeder(dbConnection)
salesChannel1 = await simpleSalesChannelFactory(dbConnection, {
name: "salesChannel1",
description: "salesChannel1",
})
salesChannel2 = await simpleSalesChannelFactory(dbConnection, {
name: "salesChannel2",
description: "salesChannel2",
})
product1 = await simpleProductFactory(dbConnection, {
title: "prod 1",
status: "published",
sales_channels: [salesChannel1],
})
product2 = await simpleProductFactory(dbConnection, {
title: "prod 2",
status: "published",
sales_channels: [salesChannel2],
})
product3 = await simpleProductFactory(dbConnection, {
title: "prod 3",
status: "published",
})
await simplePublishableApiKeyFactory(dbConnection, {
id: pubKeyId,
created_by: adminUserId,
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("returns products from a specific channel associated with a publishable key", async () => {
const api = useApi()
await api.post(
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
{
sales_channel_ids: [{ id: salesChannel1.id }],
},
adminHeaders
)
const response = await api.get(`/store/products`, {
headers: {
Authorization: "Bearer test_token",
"x-publishable-api-key": pubKeyId,
},
})
expect(response.data.products.length).toBe(1)
expect(response.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: product1.id,
}),
])
)
})
it("returns products from multiples sales channels associated with a publishable key", async () => {
const api = useApi()
await api.post(
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
{
sales_channel_ids: [
{ id: salesChannel1.id },
{ id: salesChannel2.id },
],
},
adminHeaders
)
const response = await api.get(`/store/products`, {
headers: {
Authorization: "Bearer test_token",
"x-publishable-api-key": pubKeyId,
},
})
expect(response.data.products.length).toBe(2)
expect(response.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: product1.id,
}),
expect.objectContaining({
id: product2.id,
}),
])
)
})
it("SC param overrides PK channels (but SK still needs to be in the PK's scope", async () => {
const api = useApi()
await api.post(
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
{
sales_channel_ids: [
{ id: salesChannel1.id },
{ id: salesChannel2.id },
],
},
adminHeaders
)
const response = await api.get(
`/store/products?sales_channel_id[0]=${salesChannel2.id}`,
{
headers: {
Authorization: "Bearer test_token",
"x-publishable-api-key": pubKeyId,
},
}
)
expect(response.data.products.length).toBe(1)
expect(response.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: product2.id,
}),
])
)
})
it("returns all products if PK is not passed", async () => {
const api = useApi()
await api.post(
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
{
sales_channel_ids: [
{ id: salesChannel1.id },
{ id: salesChannel2.id },
],
},
adminHeaders
)
const response = await api.get(`/store/products`, {
headers: {
Authorization: "Bearer test_token",
// "x-publishable-api-key": pubKeyId,
},
})
expect(response.data.products.length).toBe(3)
expect(response.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: product1.id,
}),
expect.objectContaining({
id: product2.id,
}),
expect.objectContaining({
id: product3.id,
}),
])
)
})
it("returns all products if passed PK doesn't have associated channels", async () => {
const api = useApi()
const response = await api.get(`/store/products`, {
headers: {
Authorization: "Bearer test_token",
"x-publishable-api-key": pubKeyId,
},
})
expect(response.data.products.length).toBe(3)
expect(response.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: product1.id,
}),
expect.objectContaining({
id: product2.id,
}),
expect.objectContaining({
id: product3.id,
}),
])
)
})
it("throws because sales channel param is not in the scope of passed PK", async () => {
const api = useApi()
await api.post(
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
{
sales_channel_ids: [{ id: salesChannel1.id }],
},
adminHeaders
)
try {
await api.get(
`/store/products?sales_channel_id[]=${salesChannel2.id}`,
{
headers: {
Authorization: "Bearer test_token",
"x-publishable-api-key": pubKeyId,
},
}
)
} catch (error) {
expect(error.response.status).toEqual(400)
expect(error.response.data.errors[0]).toEqual(
`Provided sales channel id param: ${salesChannel2.id} is not associated with the Publishable API Key passed in the header of the request.`
)
}
})
})
describe("GET /store/products/:id", () => {
const pubKeyId = IdMap.getId("pubkey-get-id")
let salesChannel1
let product1
let product2
beforeEach(async () => {
await adminSeeder(dbConnection)
salesChannel1 = await simpleSalesChannelFactory(dbConnection, {
name: "salesChannel1",
description: "salesChannel1",
})
product1 = await simpleProductFactory(dbConnection, {
title: "prod 1",
status: "published",
sales_channels: [salesChannel1],
})
product2 = await simpleProductFactory(dbConnection, {
title: "prod 2",
status: "published",
})
await simplePublishableApiKeyFactory(dbConnection, {
id: pubKeyId,
created_by: adminUserId,
})
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("retrieve a products from a specific channel associated with a publishable key", async () => {
const api = useApi()
await api.post(
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
{
sales_channel_ids: [{ id: salesChannel1.id }],
},
adminHeaders
)
const response = await api.get(`/store/products/${product1.id}`, {
headers: {
Authorization: "Bearer test_token",
"x-publishable-api-key": pubKeyId,
},
})
expect(response.data.product).toEqual(
expect.objectContaining({
id: product1.id,
})
)
})
it("return 400 because requested product is not in the SC associated with a publishable key", async () => {
const api = useApi()
await api.post(
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
{
sales_channel_ids: [{ id: salesChannel1.id }],
},
adminHeaders
)
const response = await api
.get(`/store/products/${product2.id}`, {
headers: {
Authorization: "Bearer test_token",
"x-publishable-api-key": pubKeyId,
},
})
.catch((err) => {
return err.response
})
expect(response.status).toEqual(400)
})
it("correctly returns a product if passed PK has no associated SCs", async () => {
const api = useApi()
let response = await api
.get(`/store/products/${product1.id}`, {
headers: {
Authorization: "Bearer test_token",
"x-publishable-api-key": pubKeyId,
},
})
.catch((err) => {
return err.response
})
expect(response.status).toEqual(200)
response = await api
.get(`/store/products/${product2.id}`, {
headers: {
Authorization: "Bearer test_token",
"x-publishable-api-key": pubKeyId,
},
})
.catch((err) => {
return err.response
})
expect(response.status).toEqual(200)
})
})
describe("POST /store/carts/:id", () => {
let product
const pubKeyId = IdMap.getId("pubkey-get-id")
beforeEach(async () => {
await adminSeeder(dbConnection)
await simpleRegionFactory(dbConnection, {
id: "test-region",
})
await simplePublishableApiKeyFactory(dbConnection, {
id: pubKeyId,
created_by: adminUserId,
})
product = await simpleProductFactory(dbConnection, {
sales_channels: [
{
id: "sales-channel",
name: "Sales channel",
description: "Sales channel",
},
{
id: "sales-channel2",
name: "Sales channel2",
description: "Sales channel2",
},
],
})
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("should assign sales channel to order on cart completion if PK is present in the header", async () => {
const api = useApi()
await api.post(
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
{
sales_channel_ids: [{ id: "sales-channel" }],
},
adminHeaders
)
const customerRes = await api.post("/store/customers", customerData, {
withCredentials: true,
})
const createCartRes = await api.post(
"/store/carts",
{
region_id: "test-region",
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
},
],
},
{
headers: {
Authorization: "Bearer test_token",
"x-publishable-api-key": pubKeyId,
},
}
)
const cart = createCartRes.data.cart
await api.post(`/store/carts/${cart.id}`, {
customer_id: customerRes.data.customer.id,
})
await api.post(`/store/carts/${cart.id}/payment-sessions`)
const createdOrder = await api.post(
`/store/carts/${cart.id}/complete-cart`
)
expect(createdOrder.data.type).toEqual("order")
expect(createdOrder.status).toEqual(200)
expect(createdOrder.data.data).toEqual(
expect.objectContaining({
sales_channel_id: "sales-channel",
})
)
})
it("SC from params defines where product is assigned (passed SC still has to be in the scope of PK from the header)", async () => {
const api = useApi()
await api.post(
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
{
sales_channel_ids: [
{ id: "sales-channel" },
{ id: "sales-channel2" },
],
},
adminHeaders
)
const customerRes = await api.post("/store/customers", customerData, {
withCredentials: true,
})
const createCartRes = await api.post(
"/store/carts",
{
sales_channel_id: "sales-channel2",
region_id: "test-region",
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
},
],
},
{
headers: {
Authorization: "Bearer test_token",
"x-publishable-api-key": pubKeyId,
},
}
)
const cart = createCartRes.data.cart
await api.post(`/store/carts/${cart.id}`, {
customer_id: customerRes.data.customer.id,
})
await api.post(`/store/carts/${cart.id}/payment-sessions`)
const createdOrder = await api.post(
`/store/carts/${cart.id}/complete-cart`
)
expect(createdOrder.data.type).toEqual("order")
expect(createdOrder.status).toEqual(200)
expect(createdOrder.data.data).toEqual(
expect.objectContaining({
sales_channel_id: "sales-channel2",
})
)
})
it("should throw because SC id in the body is not in the scope of PK from the header", async () => {
const api = useApi()
await api.post(
`/admin/publishable-api-keys/${pubKeyId}/sales-channels/batch`,
{
sales_channel_ids: [{ id: "sales-channel" }],
},
adminHeaders
)
try {
await api.post(
"/store/carts",
{
sales_channel_id: "sales-channel2", // SC not in the PK scope
region_id: "test-region",
items: [
{
variant_id: product.variants[0].id,
quantity: 1,
},
],
},
{
headers: {
Authorization: "Bearer test_token",
"x-publishable-api-key": pubKeyId,
},
}
)
} catch (error) {
expect(error.response.status).toEqual(400)
expect(error.response.data.errors[0]).toEqual(
`Provided sales channel id param: sales-channel2 is not associated with the Publishable API Key passed in the header of the request.`
)
}
})
})
})

View File

@@ -6,15 +6,9 @@ const { useApi } = require("../../../../helpers/use-api")
const { useDb } = require("../../../../helpers/use-db")
const {
simpleCartFactory,
simpleRegionFactory,
simpleShippingOptionFactory,
simpleCustomShippingOptionFactory,
simpleProductFactory,
simplePriceListFactory,
simpleDiscountFactory,
} = require("../../../factories")
const { IdMap } = require("medusa-test-utils")
jest.setTimeout(30000)

View File

@@ -0,0 +1,40 @@
import { NextFunction, Request, Response } from "express"
import PublishableApiKeyService from "../../../services/publishable-api-key"
export type PublishableApiKeyScopes = {
sales_channel_id: string[]
}
/**
* The middleware, in case that a key is present in the request header,
* attaches ids of resources within the scope of the key to the req object.
*
* @param req - request object
* @param res - response object
* @param next - next middleware call
*
* @throws if sales channel id is passed as a url or body param
* but that id is not in the scope defined by the PK from the header
*/
async function extendRequestParams(
req: Request & { publishableApiKeyScopes: PublishableApiKeyScopes },
res: Response,
next: NextFunction
) {
const pubKey = req.get("x-publishable-api-key")
if (pubKey) {
const publishableKeyService: PublishableApiKeyService = req.scope.resolve(
"publishableApiKeyService"
)
req.publishableApiKeyScopes = await publishableKeyService.getResourceScopes(
pubKey
)
}
next()
}
export { extendRequestParams }

View File

@@ -0,0 +1,46 @@
import { NextFunction, Request, Response } from "express"
import PublishableApiKeyService from "../../../services/publishable-api-key"
import { ProductService } from "../../../services"
/**
* The middleware check if requested product is assigned to a SC associated with PK in the header.
*
* @param req - request object
* @param res - response object
* @param next - next middleware call
*/
async function validateProductSalesChannelAssociation(
req: Request,
res: Response,
next: NextFunction
) {
const pubKey = req.get("x-publishable-api-key")
if (pubKey) {
const productService: ProductService = req.scope.resolve("productService")
const publishableKeyService: PublishableApiKeyService = req.scope.resolve(
"publishableApiKeyService"
)
const { sales_channel_id: salesChannelIds } =
await publishableKeyService.getResourceScopes(pubKey)
if (
salesChannelIds.length &&
!(await productService.isProductInSalesChannels(
req.params.id,
salesChannelIds
))
) {
req.errors = req.errors ?? []
req.errors.push(
`Product with id: ${req.params.id} is not associated with sales channels defined by the Publishable API Key passed in the header of the request.`
)
}
}
next()
}
export { validateProductSalesChannelAssociation }

View File

@@ -0,0 +1,46 @@
import { NextFunction, Request, Response } from "express"
import { PublishableApiKeyScopes } from "./extend-request-params"
/**
* The middleware will return 400 if sales channel id is passed as an url or body param
* but that id is not in the scope of the PK from the header.
*
* NOTE: must be applied after the `extendRequestParams` middleware
*
* @param req - request object
* @param res - response object
* @param next - next middleware call
*/
async function validateSalesChannelParam(
req: Request & { publishableApiKeyScopes: PublishableApiKeyScopes },
res: Response,
next: NextFunction
) {
const pubKey = req.get("x-publishable-api-key")
if (pubKey) {
const scopes = req.publishableApiKeyScopes
let channelIds = req.body.sales_channel_id || req.query.sales_channel_id
if (!channelIds) {
return next()
}
channelIds = !Array.isArray(channelIds) ? [channelIds] : channelIds
if (
scopes.sales_channel_id.length &&
!channelIds.every((sc) => scopes.sales_channel_id.includes(sc))
) {
req.errors = req.errors ?? []
req.errors.push(
`Provided sales channel id param: ${channelIds} is not associated with the Publishable API Key passed in the header of the request.`
)
}
}
next()
}
export { validateSalesChannelParam }

View File

@@ -0,0 +1,124 @@
import { IsArray, ValidateNested } from "class-validator"
import { Request, Response } from "express"
import { Type } from "class-transformer"
import { EntityManager } from "typeorm"
import { ProductBatchSalesChannel } from "../../../../types/sales-channels"
import PublishableApiKeyService from "../../../../services/publishable-api-key"
/**
* @oas [post] /publishable-api-keys/{id}/sales-channels/batch
* operationId: "PostPublishableApiKeySalesChannelsChannelsBatch"
* summary: "Add sales channel to a publishable api key scope"
* description: "Assign a batch of sales channels to a publishable api key."
* x-authenticated: true
* parameters:
* - (path) id=* {string} The ID of the Publishable Api Key.
* requestBody:
* content:
* application/json:
* schema:
* required:
* - sales_channel_ids
* properties:
* sales_channel_ids:
* description: The IDs of the sales channels to add to the publishable api key
* type: array
* items:
* type: object
* required:
* - id
* properties:
* id:
* type: string
* description: The ID of the sales channel
* 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.admin.publishableApiKeys.addSalesChannels(publishableApiKeyId, {
* sales_channel_ids: [
* {
* id: channel_id
* }
* ]
* })
* .then(({ publishable_api_key }) => {
* console.log(publishable_api_key.id);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request POST 'https://medusa-url.com/admin/publishable-api-keys/afasf/batch' \
* --header 'Authorization: Bearer {api_token}' \
* --header 'Content-Type: application/json' \
* --data-raw '{
* "sales_channel_ids": [
* {
* "id": "{sales_channel_id}"
* }
* ]
* }'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Publishable Api Key
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* publishable_api_key:
* $ref: "#/components/schemas/publishable_api_key"
* "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): Promise<void> => {
const validatedBody =
req.validatedBody as AdminPostPublishableApiKeySalesChannelsBatchReq
const { id } = req.params
const publishableApiKeyService: PublishableApiKeyService = req.scope.resolve(
"publishableApiKeyService"
)
const manager: EntityManager = req.scope.resolve("manager")
const publishableApiKey = await manager.transaction(
async (transactionManager) => {
await publishableApiKeyService
.withTransaction(transactionManager)
.addSalesChannels(
id,
validatedBody.sales_channel_ids.map((p) => p.id)
)
return await publishableApiKeyService.retrieve(id)
}
)
res.status(200).json({ publishable_api_key: publishableApiKey })
}
export class AdminPostPublishableApiKeySalesChannelsBatchReq {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductBatchSalesChannel)
sales_channel_ids: ProductBatchSalesChannel[]
}

View File

@@ -0,0 +1,124 @@
import { IsArray, ValidateNested } from "class-validator"
import { Request, Response } from "express"
import { Type } from "class-transformer"
import { EntityManager } from "typeorm"
import { ProductBatchSalesChannel } from "../../../../types/sales-channels"
import PublishableApiKeyService from "../../../../services/publishable-api-key"
/**
* @oas [delete] /publishable-api-keys/{id}/sales-channels/batch
* operationId: "DeletePublishableApiKeySalesChannelsChannelsBatch"
* summary: "Remove sales channel from a publishable api key scope"
* description: "Remove a batch of sales channels from a publishable api key."
* x-authenticated: true
* parameters:
* - (path) id=* {string} The ID of the Publishable Api Key.
* requestBody:
* content:
* application/json:
* schema:
* required:
* - sales_channel_ids
* properties:
* sales_channel_ids:
* description: The IDs of the sales channels to delete from the publishable api key
* type: array
* items:
* type: object
* required:
* - id
* properties:
* id:
* type: string
* description: The ID of the sales channel
* 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.admin.publishableApiKeys.removeSalesChannels(publishableApiKeyId, {
* sales_channel_ids: [
* {
* id: channel_id
* }
* ]
* })
* .then(({ publishable_api_key }) => {
* console.log(publishable_api_key.id);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request DELETE 'https://medusa-url.com/admin/publishable-api-keys/afasf/batch' \
* --header 'Authorization: Bearer {api_token}' \
* --header 'Content-Type: application/json' \
* --data-raw '{
* "sales_channel_ids": [
* {
* "id": "{sales_channel_id}"
* }
* ]
* }'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Publishable Api Key
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* publishable_api_key:
* $ref: "#/components/schemas/publishable_api_key"
* "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): Promise<void> => {
const validatedBody =
req.validatedBody as AdminDeletePublishableApiKeySalesChannelsBatchReq
const { id } = req.params
const publishableApiKeyService: PublishableApiKeyService = req.scope.resolve(
"publishableApiKeyService"
)
const manager: EntityManager = req.scope.resolve("manager")
const publishableApiKey = await manager.transaction(
async (transactionManager) => {
await publishableApiKeyService
.withTransaction(transactionManager)
.removeSalesChannels(
id,
validatedBody.sales_channel_ids.map((p) => p.id)
)
return await publishableApiKeyService.retrieve(id)
}
)
res.status(200).json({ publishable_api_key: publishableApiKey })
}
export class AdminDeletePublishableApiKeySalesChannelsBatchReq {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductBatchSalesChannel)
sales_channel_ids: ProductBatchSalesChannel[]
}

View File

@@ -11,6 +11,9 @@ import { PublishableApiKey } from "../../../../models"
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
import { AdminPostPublishableApiKeysReq } from "./create-publishable-api-key"
import { AdminPostPublishableApiKeysPublishableApiKeyReq } from "./update-publishable-api-key"
import { AdminDeletePublishableApiKeySalesChannelsBatchReq } from "./delete-channels-batch"
import { AdminPostPublishableApiKeySalesChannelsBatchReq } from "./add-channels-batch"
import { GetPublishableApiKeySalesChannelsParams } from "./list-publishable-api-key-sales-channels"
const route = Router()
@@ -55,6 +58,26 @@ export default (app) => {
}),
middlewares.wrap(require("./list-publishable-api-keys").default)
)
route.get(
"/:id/sales-channels",
transformQuery(GetPublishableApiKeySalesChannelsParams, { isList: true }),
middlewares.wrap(
require("./list-publishable-api-key-sales-channels").default
)
)
route.post(
"/:id/sales-channels/batch",
transformBody(AdminPostPublishableApiKeySalesChannelsBatchReq),
middlewares.wrap(require("./add-channels-batch").default)
)
route.delete(
"/:id/sales-channels/batch",
transformBody(AdminDeletePublishableApiKeySalesChannelsBatchReq),
middlewares.wrap(require("./delete-channels-batch").default)
)
}
export type AdminPublishableApiKeysRes = {
@@ -65,6 +88,9 @@ export type AdminPublishableApiKeysListRes = PaginatedResponse & {
}
export type AdminPublishableApiKeyDeleteRes = DeleteResponse
export * from "./add-channels-batch"
export * from "./delete-channels-batch"
export * from "./list-publishable-api-keys"
export * from "./list-publishable-api-key-sales-channels"
export * from "./create-publishable-api-key"
export * from "./update-publishable-api-key"

View File

@@ -0,0 +1,69 @@
import { Request, Response } from "express"
import PublishableApiKeyService from "../../../../services/publishable-api-key"
/**
* @oas [get] /publishable-api-keys/:id/sales-channels
* operationId: "GetPublishableApiKeySalesChannels"
* summary: "List PublishableApiKey's SalesChannels"
* description: "List PublishableApiKey's SalesChannels"
* x-authenticated: true
* 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.admin.publishableApiKeys.listSalesChannels()
* .then(({ sales_channels, limit, offset, count }) => {
* console.log(sales_channels)
* })
* - lang: Shell
* label: cURL
* source: |
* curl --location --request GET 'https://medusa-url.com/admin/publishable-api-keys/pk_123/sales-channels' \
* --header 'Authorization: Bearer {api_token}'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - PublishableApiKeySalesChannels
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* sales_channels:
* type: array
* items:
* $ref: "#/components/schemas/sales_channel"
* "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 publishableApiKeyService: PublishableApiKeyService = req.scope.resolve(
"publishableApiKeyService"
)
const salesChannels = await publishableApiKeyService.listSalesChannels(id)
return res.json({
sales_channels: salesChannels,
})
}
export class GetPublishableApiKeySalesChannelsParams {}

View File

@@ -2,12 +2,11 @@ import {
DateComparisonOperator,
extendedFindParamsMixin,
} from "../../../../types/common"
import { IsNumber, IsOptional, IsString, ValidateNested } from "class-validator"
import { IsOptional, IsString, ValidateNested } from "class-validator"
import { Request, Response } from "express"
import { SalesChannelService } from "../../../../services"
import { Type } from "class-transformer"
import { removeUndefinedProperties } from "../../../../utils"
/**
* @oas [get] /sales-channels

View File

@@ -23,6 +23,7 @@ import { FlagRouter } from "../../../../utils/flag-router"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
import { CartCreateProps } from "../../../../types/cart"
import { isDefined } from "../../../../utils"
import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys"
/**
* @oas [post] /carts
@@ -159,6 +160,23 @@ export default async (req, res) => {
}
}
if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) {
if (
!toCreate.sales_channel_id &&
req.publishableApiKeyScopes?.sales_channel_id.length
) {
if (req.publishableApiKeyScopes.sales_channel_id.length > 1) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"The PublishableApiKey provided in the request header has multiple associated sales channels."
)
}
toCreate.sales_channel_id =
req.publishableApiKeyScopes.sales_channel_id[0]
}
}
let cart: Cart
await entityManager.transaction(async (manager) => {
cart = await cartService.withTransaction(manager).create(toCreate)

View File

@@ -1,5 +1,6 @@
import { Router } from "express"
import "reflect-metadata"
import { RequestHandler, Router } from "express"
import { Cart, Order, Swap } from "../../../../"
import { DeleteResponse, FindParams } from "../../../../types/common"
import middlewares, {
@@ -8,6 +9,10 @@ import middlewares, {
} from "../../../middlewares"
import { StorePostCartsCartReq } from "./update-cart"
import { StorePostCartReq } from "./create-cart"
import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels"
import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys"
import { extendRequestParams } from "../../../middlewares/publishable-api-key/extend-request-params"
import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param"
const route = Router()
@@ -17,7 +22,7 @@ export default (app, container) => {
app.use("/carts", route)
if (featureFlagRouter.isFeatureEnabled("sales_channels")) {
if (featureFlagRouter.isFeatureEnabled(SalesChannelFeatureFlag.key)) {
defaultStoreCartRelations.push("sales_channel")
}
@@ -37,10 +42,21 @@ export default (app, container) => {
middlewares.wrap(require("./get-cart").default)
)
route.post(
"/",
const createMiddlewares = [
middlewareService.usePreCartCreation(),
transformBody(StorePostCartReq),
]
if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) {
createMiddlewares.push(
extendRequestParams as unknown as RequestHandler,
validateSalesChannelParam as unknown as RequestHandler
)
}
route.post(
"/",
...createMiddlewares,
middlewares.wrap(require("./create-cart").default)
)

View File

@@ -31,12 +31,14 @@ export default (app, container, config) => {
})
)
const featureFlagRouter = container.resolve("featureFlagRouter")
route.use(middlewares.authenticateCustomer())
authRoutes(route)
collectionRoutes(route)
customerRoutes(route, container)
productRoutes(route)
productRoutes(route, featureFlagRouter)
productTypesRoutes(route)
orderRoutes(route)
orderEditRoutes(route)

View File

@@ -1,17 +1,32 @@
import { Router } from "express"
import { RequestHandler, Router } from "express"
import "reflect-metadata"
import { Product } from "../../../.."
import { PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares"
import { FlagRouter } from "../../../../utils/flag-router"
import { PaginatedResponse } from "../../../../types/common"
import { extendRequestParams } from "../../../middlewares/publishable-api-key/extend-request-params"
import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys"
import { validateProductSalesChannelAssociation } from "../../../middlewares/publishable-api-key/validate-product-sales-channel-association"
import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param"
const route = Router()
export default (app) => {
export default (app, featureFlagRouter: FlagRouter) => {
app.use("/products", route)
if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) {
route.use(
"/",
extendRequestParams as unknown as RequestHandler,
validateSalesChannelParam as unknown as RequestHandler
)
route.use("/:id", validateProductSalesChannelAssociation)
}
route.get("/", middlewares.wrap(require("./list-products").default))
route.post("/search", middlewares.wrap(require("./search").default))
route.get("/:id", middlewares.wrap(require("./get-product").default))
route.post("/search", middlewares.wrap(require("./search").default))
return app
}

View File

@@ -25,6 +25,8 @@ import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators
import { validator } from "../../../../utils/validator"
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"
/**
* @oas [get] /products
@@ -195,8 +197,18 @@ export default async (req, res) => {
const cartService: CartService = req.scope.resolve("cartService")
const regionService: RegionService = req.scope.resolve("regionService")
const featureFlagRouter: FlagRouter = req.scope.resolve("featureFlagRouter")
const validated = await validator(StoreGetProductsParams, req.query)
if (featureFlagRouter.isFeatureEnabled(PublishableAPIKeysFeatureFlag.key)) {
if (req.publishableApiKeyScopes?.sales_channel_id.length) {
validated.sales_channel_id =
validated.sales_channel_id ||
req.publishableApiKeyScopes.sales_channel_id
}
}
const filterableFields: StoreGetProductsParams = omit(validated, [
"fields",
"expand",

View File

@@ -397,6 +397,22 @@ export class ProductRepository extends Repository<Product> {
return [products, count]
}
public async isProductInSalesChannels(
id: string,
salesChannelIds: string[]
): Promise<boolean> {
return (
(await this.createQueryBuilder("product")
.leftJoin(
"product.sales_channels",
"sales_channels",
"sales_channels.id IN (:...salesChannelIds)",
{ salesChannelIds }
)
.getCount()) > 0
)
}
private _cleanOptions(
options: FindWithoutRelationsOptions
): WithRequiredProperty<FindWithoutRelationsOptions, "where"> {

View File

@@ -0,0 +1,78 @@
import { EntityRepository, In, Repository } from "typeorm"
import { PublishableApiKeySalesChannel, SalesChannel } from "../models"
@EntityRepository(PublishableApiKeySalesChannel)
export class PublishableApiKeySalesChannelRepository extends Repository<PublishableApiKeySalesChannel> {
/**
* Query a list of sales channels that are assigned to the publishable key scope
*
* @param publishableApiKeyId - id of the key to retrieve channels for
*/
public async findSalesChannels(
publishableApiKeyId: string
): Promise<SalesChannel[]> {
const data = await this.createQueryBuilder("PublishableKeySalesChannel")
.select("PublishableKeySalesChannel.sales_channel_id")
.innerJoinAndMapOne(
"PublishableKeySalesChannel.sales_channel_id",
SalesChannel,
"SalesChannel",
"PublishableKeySalesChannel.sales_channel_id = SalesChannel.id"
)
.where(
"PublishableKeySalesChannel.publishable_key_id = :publishableApiKeyId",
{
publishableApiKeyId,
}
)
.getMany()
return data.map(
(record) => record.sales_channel_id as unknown as SalesChannel
)
}
/**
* Assign (multiple) sales channels to the Publishable Key scope
*
* @param publishableApiKeyId - publishable key id
* @param salesChannelIds - an array of SC ids
*/
public async addSalesChannels(
publishableApiKeyId: string,
salesChannelIds: string[]
): Promise<void> {
await this.createQueryBuilder()
.insert()
.into("publishable_api_key_sales_channel")
.values(
salesChannelIds.map((id) => ({
sales_channel_id: id,
publishable_key_id: publishableApiKeyId,
}))
)
.orIgnore()
.execute()
}
/**
* Remove multiple sales channels from the PK scope
*
* @param publishableApiKeyId -publishable key id
* @param salesChannelIds - an array of SC ids
*/
public async removeSalesChannels(
publishableApiKeyId: string,
salesChannelIds: string[]
): Promise<void> {
await this.createQueryBuilder()
.delete()
.from("publishable_api_key_sales_channel")
.where({
sales_channel_id: In(salesChannelIds),
publishable_key_id: publishableApiKeyId,
})
.execute()
}
}

View File

@@ -1,7 +1,7 @@
import { flatten, groupBy, merge } from "lodash"
import { EntityRepository, FindManyOptions, Repository } from "typeorm"
import { PublishableApiKey } from "../models/publishable-api-key"
import { PublishableApiKey } from "../models"
@EntityRepository(PublishableApiKey)
export class PublishableApiKeyRepository extends Repository<PublishableApiKey> {

View File

@@ -27,8 +27,12 @@ describe("PublishableApiKeyService", () => {
},
})
const publishableApiKeySalesChannelRepository = MockRepository({})
const publishableApiKeyService = new PublishableApiKeyService({
manager: MockManager,
publishableApiKeySalesChannelRepository:
publishableApiKeySalesChannelRepository,
publishableApiKeyRepository: publishableApiKeyRepository,
eventBusService: EventBusServiceMock as unknown as EventBusService,
})

View File

@@ -324,6 +324,29 @@ class ProductService extends TransactionBaseService {
return await productTagRepo.listTagsByUsage(count)
}
/**
* Check if the product is assigned to at least one of the provided sales channels.
*
* @param id - product id
* @param salesChannelIds - an array of sales channel ids
*/
async isProductInSalesChannels(
id: string,
salesChannelIds: string[]
): Promise<boolean> {
const product = await this.retrieve_(
{ id },
{ relations: ["sales_channels"] }
)
// TODO: reimplement this to use db level check
const productsSalesChannels = product.sales_channels.map(
(channel) => channel.id
)
return productsSalesChannels.some((id) => salesChannelIds.includes(id))
}
/**
* Creates a product.
* @param productObject - the product to create

View File

@@ -3,7 +3,7 @@ import { MedusaError } from "medusa-core-utils"
import { PublishableApiKeyRepository } from "../repositories/publishable-api-key"
import { FindConfig, Selector } from "../types/common"
import { PublishableApiKey } from "../models"
import { PublishableApiKey, SalesChannel } from "../models"
import { TransactionBaseService } from "../interfaces"
import EventBusService from "./event-bus"
import { buildQuery, isDefined, isString } from "../utils"
@@ -11,12 +11,14 @@ import {
CreatePublishableApiKeyInput,
UpdatePublishableApiKeyInput,
} from "../types/publishable-api-key"
import { PublishableApiKeySalesChannelRepository } from "../repositories/publishable-api-key-sales-channel"
type InjectedDependencies = {
manager: EntityManager
eventBusService: EventBusService
publishableApiKeyRepository: typeof PublishableApiKeyRepository
publishableApiKeySalesChannelRepository: typeof PublishableApiKeySalesChannelRepository
}
/**
@@ -33,17 +35,21 @@ class PublishableApiKeyService extends TransactionBaseService {
protected readonly eventBusService_: EventBusService
protected readonly publishableApiKeyRepository_: typeof PublishableApiKeyRepository
protected readonly publishableApiKeySalesChannelRepository_: typeof PublishableApiKeySalesChannelRepository
constructor({
manager,
eventBusService,
publishableApiKeyRepository,
publishableApiKeySalesChannelRepository,
}: InjectedDependencies) {
super(arguments[0])
this.manager_ = manager
this.eventBusService_ = eventBusService
this.publishableApiKeyRepository_ = publishableApiKeyRepository
this.publishableApiKeySalesChannelRepository_ =
publishableApiKeySalesChannelRepository
}
/**
@@ -247,6 +253,91 @@ class PublishableApiKeyService extends TransactionBaseService {
const pubKey = await this.retrieve(publishableApiKeyId)
return pubKey.revoked_by === null
}
/**
* Associate provided sales channels with the publishable api key.
*
* @param publishableApiKeyId
* @param salesChannelIds
*/
async addSalesChannels(
publishableApiKeyId: string,
salesChannelIds: string[]
): Promise<void | never> {
return await this.atomicPhase_(async (transactionManager) => {
const pubKeySalesChannelRepo = transactionManager.getCustomRepository(
this.publishableApiKeySalesChannelRepository_
)
await pubKeySalesChannelRepo.addSalesChannels(
publishableApiKeyId,
salesChannelIds
)
})
}
/**
* Remove provided sales channels from the publishable api key scope.
*
* @param publishableApiKeyId
* @param salesChannelIds
*/
async removeSalesChannels(
publishableApiKeyId: string,
salesChannelIds: string[]
): Promise<void | never> {
return await this.atomicPhase_(async (transactionManager) => {
const pubKeySalesChannelRepo = transactionManager.getCustomRepository(
this.publishableApiKeySalesChannelRepository_
)
await pubKeySalesChannelRepo.removeSalesChannels(
publishableApiKeyId,
salesChannelIds
)
})
}
/**
* List SalesChannels associated with the PublishableKey
*
* @param publishableApiKeyId - id of the key SalesChannels are listed for
*/
async listSalesChannels(
publishableApiKeyId: string
): Promise<SalesChannel[]> {
const manager = this.manager_
const pubKeySalesChannelRepo = manager.getCustomRepository(
this.publishableApiKeySalesChannelRepository_
)
return await pubKeySalesChannelRepo.findSalesChannels(publishableApiKeyId)
}
/**
* Get a map of resources ids that are withing the key's scope.
*
* @param publishableApiKeyId
*/
async getResourceScopes(
publishableApiKeyId: string
): Promise<{ sales_channel_id: string[] }> {
const manager = this.manager_
const pubKeySalesChannelRepo = manager.getCustomRepository(
this.publishableApiKeySalesChannelRepository_
)
const salesChannels = await pubKeySalesChannelRepo.find({
select: ["sales_channel_id"],
where: { publishable_key_id: publishableApiKeyId },
})
return {
sales_channel_id: salesChannels.map(
({ sales_channel_id }) => sales_channel_id
),
}
}
}
export default PublishableApiKeyService